@@ -86,6 +94,7 @@ export default {
{ key: 'start_date_time', label: 'Start Time' },
{ key: 'end_date_time', label: 'End Time' },
{ key: 'run_time', label: 'Run Time' },
+ { key: 'script_revision_id', label: 'Script Revision' },
{ key: 'tags', label: 'Tags' },
],
startingSession: false,
@@ -93,7 +102,7 @@ export default {
};
},
computed: {
- ...mapGetters(['SHOW_SESSIONS_LIST', 'CURRENT_SHOW_SESSION', 'INTERNAL_UUID', 'IS_SHOW_EXECUTOR', 'IS_SHOW_EDITOR']),
+ ...mapGetters(['SHOW_SESSIONS_LIST', 'CURRENT_SHOW_SESSION', 'INTERNAL_UUID', 'IS_SHOW_EXECUTOR', 'IS_SHOW_EDITOR', 'SCRIPT_REVISIONS']),
},
methods: {
contrastColor,
@@ -136,6 +145,13 @@ export default {
const diff = endDate - startDate;
return msToTimerString(diff);
},
+ scriptRevisionLabel(revisionId) {
+ const revision = this.SCRIPT_REVISIONS.find((rev) => rev.id === revisionId);
+ if (revision) {
+ return `${revision.revision}: ${revision.description}`;
+ }
+ return 'N/A';
+ }
},
};
diff --git a/server/alembic_config/env.py b/server/alembic_config/env.py
index 4be3a1c7..ec9a8421 100644
--- a/server/alembic_config/env.py
+++ b/server/alembic_config/env.py
@@ -7,6 +7,7 @@
from models import models
+
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
diff --git a/server/alembic_config/versions/29471f7cf7d2_user_deletion.py b/server/alembic_config/versions/29471f7cf7d2_user_deletion.py
index 0275c241..29f56d4a 100644
--- a/server/alembic_config/versions/29471f7cf7d2_user_deletion.py
+++ b/server/alembic_config/versions/29471f7cf7d2_user_deletion.py
@@ -8,9 +8,9 @@
from typing import Sequence, Union
-import sqlalchemy as sa
from alembic import op
+
# revision identifiers, used by Alembic.
revision: str = "29471f7cf7d2"
down_revision: Union[str, None] = "be353176c064"
diff --git a/server/alembic_config/versions/42d0eaa5d07e_cleanup_orphaned_script_objects.py b/server/alembic_config/versions/42d0eaa5d07e_cleanup_orphaned_script_objects.py
index c908b7a8..4c179e2c 100644
--- a/server/alembic_config/versions/42d0eaa5d07e_cleanup_orphaned_script_objects.py
+++ b/server/alembic_config/versions/42d0eaa5d07e_cleanup_orphaned_script_objects.py
@@ -15,8 +15,8 @@
from typing import Sequence, Union
-from alembic import op
import sqlalchemy as sa
+from alembic import op
# revision identifiers, used by Alembic.
diff --git a/server/alembic_config/versions/4400e44b4455_add_script_revision_association_to_show_.py b/server/alembic_config/versions/4400e44b4455_add_script_revision_association_to_show_.py
new file mode 100644
index 00000000..3f3ef903
--- /dev/null
+++ b/server/alembic_config/versions/4400e44b4455_add_script_revision_association_to_show_.py
@@ -0,0 +1,113 @@
+"""Add script revision association to show session
+
+Revision ID: 4400e44b4455
+Revises: 4632b14b6e67
+Create Date: 2026-01-08 00:12:25.730083
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+
+# revision identifiers, used by Alembic.
+revision: str = "4400e44b4455"
+down_revision: Union[str, None] = "4632b14b6e67"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ # Step 1: Add column as nullable initially (required for backfill)
+ with op.batch_alter_table("showsession", schema=None) as batch_op:
+ batch_op.add_column(
+ sa.Column("script_revision_id", sa.Integer(), nullable=True)
+ )
+ batch_op.alter_column("show_id", existing_type=sa.INTEGER(), nullable=False)
+
+ # Step 2: Backfill script_revision_id from script.current_revision
+ connection = op.get_bind()
+
+ # Check for shows with multiple scripts (should be 1:1 relationship)
+ multi_script_shows = connection.execute(
+ sa.text("""
+ SELECT show_id, COUNT(*) as script_count
+ FROM script
+ GROUP BY show_id
+ HAVING COUNT(*) > 1
+ """)
+ ).fetchall()
+
+ if multi_script_shows:
+ print("Warning: Found shows with multiple scripts (expected 1:1 relationship):")
+ for row in multi_script_shows:
+ print(f" Show ID {row[0]}: {row[1]} scripts")
+ print("Using the first script (ordered by ID) for each show...")
+
+ # Backfill sessions with script_revision_id
+ # For shows with multiple scripts, use the first script (ordered by ID)
+ connection.execute(
+ sa.text("""
+ UPDATE showsession
+ SET script_revision_id = (
+ SELECT s.current_revision
+ FROM script s
+ WHERE s.show_id = showsession.show_id
+ ORDER BY s.id
+ LIMIT 1
+ )
+ WHERE script_revision_id IS NULL
+ AND EXISTS (
+ SELECT 1 FROM script s
+ WHERE s.show_id = showsession.show_id
+ AND s.current_revision IS NOT NULL
+ )
+ """)
+ )
+
+ # Check for sessions that couldn't be backfilled
+ orphaned_sessions = connection.execute(
+ sa.text("""
+ SELECT id, show_id
+ FROM showsession
+ WHERE script_revision_id IS NULL
+ """)
+ ).fetchall()
+
+ if orphaned_sessions:
+ print("\nERROR: Found sessions without valid script revisions:")
+ for row in orphaned_sessions:
+ print(f" Session ID {row[0]}, Show ID {row[1]}")
+ raise Exception(
+ "Cannot complete migration: Some sessions do not have a corresponding "
+ "script with a current_revision. Please either delete these sessions or "
+ "ensure all shows have a script with a current_revision set."
+ )
+
+ # Step 3: Make column non-nullable and add foreign key constraint
+ with op.batch_alter_table("showsession", schema=None) as batch_op:
+ batch_op.alter_column("script_revision_id", nullable=False)
+ batch_op.create_foreign_key(
+ batch_op.f("fk_showsession_script_revision_id_script_revisions"),
+ "script_revisions",
+ ["script_revision_id"],
+ ["id"],
+ )
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("showsession", schema=None) as batch_op:
+ batch_op.drop_constraint(
+ batch_op.f("fk_showsession_script_revision_id_script_revisions"),
+ type_="foreignkey",
+ )
+ batch_op.alter_column("show_id", existing_type=sa.INTEGER(), nullable=True)
+ batch_op.drop_column("script_revision_id")
+
+ # ### end Alembic commands ###
diff --git a/server/alembic_config/versions/4632b14b6e67_add_session_tags_and_associations.py b/server/alembic_config/versions/4632b14b6e67_add_session_tags_and_associations.py
index 2ea3f3a1..33c43e5d 100644
--- a/server/alembic_config/versions/4632b14b6e67_add_session_tags_and_associations.py
+++ b/server/alembic_config/versions/4632b14b6e67_add_session_tags_and_associations.py
@@ -8,8 +8,8 @@
from typing import Sequence, Union
-from alembic import op
import sqlalchemy as sa
+from alembic import op
# revision identifiers, used by Alembic.
diff --git a/server/alembic_config/versions/49df18ea818d_add_compiled_scripts_table.py b/server/alembic_config/versions/49df18ea818d_add_compiled_scripts_table.py
index 8c65a017..882ec84b 100644
--- a/server/alembic_config/versions/49df18ea818d_add_compiled_scripts_table.py
+++ b/server/alembic_config/versions/49df18ea818d_add_compiled_scripts_table.py
@@ -11,6 +11,7 @@
import sqlalchemy as sa
from alembic import op
+
# revision identifiers, used by Alembic.
revision: str = "49df18ea818d"
down_revision: Union[str, None] = "f154c79d86de"
diff --git a/server/alembic_config/versions/7df32f85a5a2_add_systemsettings_table_for_jwt_secret_.py b/server/alembic_config/versions/7df32f85a5a2_add_systemsettings_table_for_jwt_secret_.py
index dab820e9..f900ebfe 100644
--- a/server/alembic_config/versions/7df32f85a5a2_add_systemsettings_table_for_jwt_secret_.py
+++ b/server/alembic_config/versions/7df32f85a5a2_add_systemsettings_table_for_jwt_secret_.py
@@ -11,6 +11,7 @@
import sqlalchemy as sa
from alembic import op
+
# revision identifiers, used by Alembic.
revision: str = "7df32f85a5a2"
down_revision: Union[str, None] = "a4d42ccfb71a"
diff --git a/server/alembic_config/versions/7fe8320b38c9_add_interval_state_to_show_session.py b/server/alembic_config/versions/7fe8320b38c9_add_interval_state_to_show_session.py
index 3e2ec36b..66936247 100644
--- a/server/alembic_config/versions/7fe8320b38c9_add_interval_state_to_show_session.py
+++ b/server/alembic_config/versions/7fe8320b38c9_add_interval_state_to_show_session.py
@@ -11,6 +11,7 @@
import sqlalchemy as sa
from alembic import op
+
# revision identifiers, used by Alembic.
revision: str = "7fe8320b38c9"
down_revision: Union[str, None] = "7df32f85a5a2"
diff --git a/server/alembic_config/versions/8c78b9c89ee6_add_user_last_seen_time.py b/server/alembic_config/versions/8c78b9c89ee6_add_user_last_seen_time.py
index c139fc36..58d30844 100644
--- a/server/alembic_config/versions/8c78b9c89ee6_add_user_last_seen_time.py
+++ b/server/alembic_config/versions/8c78b9c89ee6_add_user_last_seen_time.py
@@ -11,6 +11,7 @@
import sqlalchemy as sa
from alembic import op
+
# revision identifiers, used by Alembic.
revision: str = "8c78b9c89ee6"
down_revision: Union[str, None] = "49df18ea818d"
diff --git a/server/alembic_config/versions/9f76c42e225e_replace_stage_direction_with_line_type_.py b/server/alembic_config/versions/9f76c42e225e_replace_stage_direction_with_line_type_.py
index 59497bef..465fc966 100644
--- a/server/alembic_config/versions/9f76c42e225e_replace_stage_direction_with_line_type_.py
+++ b/server/alembic_config/versions/9f76c42e225e_replace_stage_direction_with_line_type_.py
@@ -8,8 +8,8 @@
from typing import Sequence, Union
-from alembic import op
import sqlalchemy as sa
+from alembic import op
# revision identifiers, used by Alembic.
diff --git a/server/alembic_config/versions/a39ac9e9f085_add_user_settings.py b/server/alembic_config/versions/a39ac9e9f085_add_user_settings.py
index 065bd76d..1d49d190 100644
--- a/server/alembic_config/versions/a39ac9e9f085_add_user_settings.py
+++ b/server/alembic_config/versions/a39ac9e9f085_add_user_settings.py
@@ -11,6 +11,7 @@
import sqlalchemy as sa
from alembic import op
+
# revision identifiers, used by Alembic.
revision: str = "a39ac9e9f085"
down_revision: Union[str, None] = "a44e01459595"
diff --git a/server/alembic_config/versions/a44e01459595_add_stage_direction_styles.py b/server/alembic_config/versions/a44e01459595_add_stage_direction_styles.py
index d64708c8..092dcc0e 100644
--- a/server/alembic_config/versions/a44e01459595_add_stage_direction_styles.py
+++ b/server/alembic_config/versions/a44e01459595_add_stage_direction_styles.py
@@ -11,6 +11,7 @@
import sqlalchemy as sa
from alembic import op
+
# revision identifiers, used by Alembic.
revision: str = "a44e01459595"
down_revision: Union[str, None] = "d4f66f58158b"
diff --git a/server/alembic_config/versions/a4d42ccfb71a_fix_session_table_foreign_key_reference.py b/server/alembic_config/versions/a4d42ccfb71a_fix_session_table_foreign_key_reference.py
index cc246c72..dc74208e 100644
--- a/server/alembic_config/versions/a4d42ccfb71a_fix_session_table_foreign_key_reference.py
+++ b/server/alembic_config/versions/a4d42ccfb71a_fix_session_table_foreign_key_reference.py
@@ -8,9 +8,9 @@
from typing import Sequence, Union
-import sqlalchemy as sa
from alembic import op
+
# revision identifiers, used by Alembic.
revision: str = "a4d42ccfb71a"
down_revision: Union[str, None] = "29471f7cf7d2"
diff --git a/server/alembic_config/versions/bb9b28a04946_rename_user_settings_table_to_user_.py b/server/alembic_config/versions/bb9b28a04946_rename_user_settings_table_to_user_.py
index 656b616e..b0e65825 100644
--- a/server/alembic_config/versions/bb9b28a04946_rename_user_settings_table_to_user_.py
+++ b/server/alembic_config/versions/bb9b28a04946_rename_user_settings_table_to_user_.py
@@ -8,9 +8,9 @@
from typing import Sequence, Union
-import sqlalchemy as sa
from alembic import op
+
# revision identifiers, used by Alembic.
revision: str = "bb9b28a04946"
down_revision: Union[str, None] = "7fe8320b38c9"
diff --git a/server/alembic_config/versions/be353176c064_detach_users_from_shows.py b/server/alembic_config/versions/be353176c064_detach_users_from_shows.py
index 3dd8c168..838a20e2 100644
--- a/server/alembic_config/versions/be353176c064_detach_users_from_shows.py
+++ b/server/alembic_config/versions/be353176c064_detach_users_from_shows.py
@@ -11,6 +11,7 @@
import sqlalchemy as sa
from alembic import op
+
# revision identifiers, used by Alembic.
revision: str = "be353176c064"
down_revision: Union[str, None] = "a39ac9e9f085"
diff --git a/server/alembic_config/versions/d4f66f58158b_initial_alembic_revision.py b/server/alembic_config/versions/d4f66f58158b_initial_alembic_revision.py
index 1b2cd1db..9b3b0124 100644
--- a/server/alembic_config/versions/d4f66f58158b_initial_alembic_revision.py
+++ b/server/alembic_config/versions/d4f66f58158b_initial_alembic_revision.py
@@ -8,8 +8,6 @@
from typing import Sequence, Union
-import sqlalchemy as sa
-from alembic import op
# revision identifiers, used by Alembic.
revision: str = "d4f66f58158b"
diff --git a/server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py b/server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py
index ce1e13cc..046ff9fc 100644
--- a/server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py
+++ b/server/alembic_config/versions/e1a2b3c4d5e6_add_user_api_token.py
@@ -11,6 +11,7 @@
import sqlalchemy as sa
from alembic import op
+
# revision identifiers, used by Alembic.
revision: str = "e1a2b3c4d5e6"
down_revision: Union[str, None] = "8c78b9c89ee6"
diff --git a/server/alembic_config/versions/f154c79d86de_add_cue_panel_right_setting.py b/server/alembic_config/versions/f154c79d86de_add_cue_panel_right_setting.py
index d009ef0d..8be4fa31 100644
--- a/server/alembic_config/versions/f154c79d86de_add_cue_panel_right_setting.py
+++ b/server/alembic_config/versions/f154c79d86de_add_cue_panel_right_setting.py
@@ -11,6 +11,7 @@
import sqlalchemy as sa
from alembic import op
+
# revision identifiers, used by Alembic.
revision: str = "f154c79d86de"
down_revision: Union[str, None] = "ff9f875915b6"
diff --git a/server/alembic_config/versions/f365c2b2b234_add_script_mode_to_show.py b/server/alembic_config/versions/f365c2b2b234_add_script_mode_to_show.py
index 3b94baa2..abfcbc8a 100644
--- a/server/alembic_config/versions/f365c2b2b234_add_script_mode_to_show.py
+++ b/server/alembic_config/versions/f365c2b2b234_add_script_mode_to_show.py
@@ -8,8 +8,8 @@
from typing import Sequence, Union
-from alembic import op
import sqlalchemy as sa
+from alembic import op
# revision identifiers, used by Alembic.
diff --git a/server/alembic_config/versions/fa8ee07e45fc_fix_orphaned_script_revisions.py b/server/alembic_config/versions/fa8ee07e45fc_fix_orphaned_script_revisions.py
index 40b01b8b..cc1ca328 100644
--- a/server/alembic_config/versions/fa8ee07e45fc_fix_orphaned_script_revisions.py
+++ b/server/alembic_config/versions/fa8ee07e45fc_fix_orphaned_script_revisions.py
@@ -8,8 +8,8 @@
from typing import Sequence, Union
-from alembic import op
import sqlalchemy as sa
+from alembic import op
# revision identifiers, used by Alembic.
diff --git a/server/alembic_config/versions/ff9f875915b6_add_user_settings_table.py b/server/alembic_config/versions/ff9f875915b6_add_user_settings_table.py
index 5f6cbbc1..2651358a 100644
--- a/server/alembic_config/versions/ff9f875915b6_add_user_settings_table.py
+++ b/server/alembic_config/versions/ff9f875915b6_add_user_settings_table.py
@@ -11,6 +11,7 @@
import sqlalchemy as sa
from alembic import op
+
# revision identifiers, used by Alembic.
revision: str = "ff9f875915b6"
down_revision: Union[str, None] = "bb9b28a04946"
diff --git a/server/controllers/api/show/session/sessions.py b/server/controllers/api/show/session/sessions.py
index 877ac4bb..5d1afa91 100644
--- a/server/controllers/api/show/session/sessions.py
+++ b/server/controllers/api/show/session/sessions.py
@@ -1,8 +1,10 @@
from datetime import UTC, datetime
+from typing import List
from sqlalchemy import select
from tornado import escape
+from models.script import Script
from models.session import Interval, Session, ShowSession
from models.show import Show
from rbac.role import Role
@@ -86,8 +88,19 @@ async def post(self):
)
return
+ scripts: List[Script] = session.scalars(
+ select(Script).where(Script.show_id == show.id)
+ ).all()
+ if len(scripts) != 1:
+ self.set_status(400)
+ await self.finish(
+ {"message": "Unable to start show session without a script"}
+ )
+ return
+
show_session = ShowSession(
show_id=show_id,
+ script_revision_id=scripts[0].current_revision,
start_date_time=datetime.now(UTC),
end_date_time=None,
client_internal_id=user_session.internal_id,
diff --git a/server/digi_server/app_server.py b/server/digi_server/app_server.py
index ec8879e8..bf4d6a46 100644
--- a/server/digi_server/app_server.py
+++ b/server/digi_server/app_server.py
@@ -105,15 +105,15 @@ class AlembicVersion(self._db.Model):
# Configure the JWT service once we have set up the database
self.jwt_service = self._configure_jwt()
- # Check for presence of admin user, and update settings to match
+ # On startup, perform the following checks/operations with the database:
with self._db.sessionmaker() as session:
+ # 1. Check for presence of admin user, and update settings to match
any_admin = session.scalars(select(User).where(User.is_admin)).first()
has_admin = any_admin is not None
self.digi_settings.settings["has_admin_user"].set_value(has_admin, False)
self.digi_settings._save()
- # Check the show we are expecting to be loaded exists
- with self._db.sessionmaker() as session:
+ # 2. Check the show we are expecting to be loaded exists
current_show = self.digi_settings.settings.get("current_show").get_value()
if current_show:
show = session.get(Show, current_show)
@@ -123,13 +123,15 @@ class AlembicVersion(self._db.Model):
)
self.digi_settings.settings["current_show"].set_to_default()
self.digi_settings._save()
+ current_show = None
- # If there is a live session in progress, clean up the current client ID
- with self._db.sessionmaker() as session:
- current_show = self.digi_settings.settings.get("current_show").get_value()
+ # 3. If there is a live session in progress:
+ # 3.1. Clean up the current client ID
+ # 3.2. Check that the revision of the script matches the current one,
+ # if not then load the revision being used
if current_show:
show = session.get(Show, current_show)
- if show and show.current_session_id:
+ if show.current_session_id:
show_session: ShowSession = session.get(
ShowSession, show.current_session_id
)
@@ -137,15 +139,36 @@ class AlembicVersion(self._db.Model):
show_session.last_client_internal_id = (
show_session.client_internal_id
)
+
+ scripts: List[Script] = session.scalars(
+ select(Script).where(Script.show_id == show.id)
+ ).all()
+ if len(scripts) != 1:
+ get_logger().error(
+ "Unable to validate live session script revision, "
+ "show does not have exactly one script. Resetting."
+ )
+ show.current_session_id = None
+ else:
+ current_script: Script = scripts[0]
+ if (
+ show_session.script_revision_id
+ != current_script.current_revision
+ ):
+ get_logger().warning(
+ "Live session script revision does not match "
+ "current script revision. Updating to use "
+ "current script revision."
+ )
+ current_script.current_revision = (
+ show_session.script_revision_id
+ )
else:
- get_logger().warning(
- "Current show session not found. Resetting."
- )
+ get_logger().error("Current show session not found. Resetting.")
show.current_session_id = None
session.commit()
- # Clear out all sessions since we are starting the app up
- with self._db.sessionmaker() as session:
+ # 4. Clear out all sessions since we are starting the app up
get_logger().debug("Emptying out sessions table!")
session.execute(delete(Session))
session.commit()
diff --git a/server/models/session.py b/server/models/session.py
index 0d9d0711..cc72e09d 100644
--- a/server/models/session.py
+++ b/server/models/session.py
@@ -9,6 +9,7 @@
if TYPE_CHECKING:
+ from models.script import ScriptRevision
from models.show import Show
from models.user import User
@@ -48,7 +49,8 @@ class ShowSession(db.Model):
__tablename__ = "showsession"
id: Mapped[int] = mapped_column(primary_key=True)
- show_id: Mapped[int | None] = mapped_column(ForeignKey("shows.id"))
+ show_id: Mapped[int] = mapped_column(ForeignKey("shows.id"))
+ script_revision_id: Mapped[int] = mapped_column(ForeignKey("script_revisions.id"))
start_date_time: Mapped[datetime.datetime | None] = mapped_column()
end_date_time: Mapped[datetime.datetime | None] = mapped_column()
@@ -65,6 +67,9 @@ class ShowSession(db.Model):
)
show: Mapped["Show"] = relationship(uselist=False, foreign_keys=[show_id])
+ revision: Mapped["ScriptRevision"] = relationship(
+ uselist=False, foreign_keys=[script_revision_id]
+ )
user: Mapped["User"] = relationship(uselist=False, foreign_keys=[user_id])
client: Mapped["Session"] = relationship(
foreign_keys=[client_internal_id],
diff --git a/server/pyproject.toml b/server/pyproject.toml
index 1884e827..57076a10 100644
--- a/server/pyproject.toml
+++ b/server/pyproject.toml
@@ -132,11 +132,6 @@ ignore = [
"TD003", # Missing TODO link
]
-[tool.ruff.lint.per-file-ignores]
-# Ignore test directory (matching pylint's ignore-paths)
-"test/**" = ["ALL"]
-"alembic_config/**" = ["ALL"]
-
[tool.ruff.lint.isort]
# Match isort configuration
known-first-party = ["digi_server", "models", "controllers", "utils", "schemas", "rbac", "registry"]
diff --git a/server/test/controllers/api/show/script/test_config.py b/server/test/controllers/api/show/script/test_config.py
index 4ad3dc5c..19f23d26 100644
--- a/server/test/controllers/api/show/script/test_config.py
+++ b/server/test/controllers/api/show/script/test_config.py
@@ -1,5 +1,4 @@
import tornado.escape
-from sqlalchemy import select
from models.session import Session
from test.conftest import DigiScriptTestCase
diff --git a/server/test/controllers/api/show/script/test_orphan_deletion.py b/server/test/controllers/api/show/script/test_orphan_deletion.py
index 33eaf88c..471744ef 100644
--- a/server/test/controllers/api/show/script/test_orphan_deletion.py
+++ b/server/test/controllers/api/show/script/test_orphan_deletion.py
@@ -4,22 +4,20 @@
are properly deleted when all references are removed.
"""
-from test.conftest import DigiScriptTestCase
-
import tornado.escape
from sqlalchemy import select
from models.cue import Cue, CueAssociation, CueType
from models.script import (
- ScriptLineType,
Script,
ScriptCuts,
ScriptLine,
ScriptLinePart,
- ScriptLineRevisionAssociation,
ScriptRevision,
)
from models.show import Act, Character, Scene, Show, ShowScriptType
+from models.user import User
+from test.conftest import DigiScriptTestCase
class TestOrphanedLineDeletion(DigiScriptTestCase):
@@ -32,8 +30,6 @@ class TestOrphanedLineDeletion(DigiScriptTestCase):
def setUp(self):
super().setUp()
with self._app.get_db().sessionmaker() as session:
- from models.user import User
-
user = User(username="admin", password="hashed", is_admin=True)
session.add(user)
session.flush()
diff --git a/server/test/controllers/api/show/script/test_revisions.py b/server/test/controllers/api/show/script/test_revisions.py
index 510a9da3..ec7e049b 100644
--- a/server/test/controllers/api/show/script/test_revisions.py
+++ b/server/test/controllers/api/show/script/test_revisions.py
@@ -1,18 +1,16 @@
-from test.conftest import DigiScriptTestCase
-
import tornado.escape
-from sqlalchemy import func, select
+from sqlalchemy import select
from models.cue import Cue, CueAssociation, CueType
from models.script import (
- ScriptLineType,
Script,
ScriptLine,
- ScriptLinePart,
ScriptLineRevisionAssociation,
ScriptRevision,
)
from models.show import Act, Character, Scene, Show, ShowScriptType
+from models.user import User
+from test.conftest import DigiScriptTestCase
class TestScriptRevisionsController(DigiScriptTestCase):
@@ -159,8 +157,6 @@ class TestScriptRevisionCreate(DigiScriptTestCase):
def setUp(self):
super().setUp()
with self._app.get_db().sessionmaker() as session:
- from models.user import User
-
# Create admin user for authentication
user = User(username="admin", password="hashed", is_admin=True)
session.add(user)
@@ -237,8 +233,6 @@ class TestScriptRevisionDelete(DigiScriptTestCase):
def setUp(self):
super().setUp()
with self._app.get_db().sessionmaker() as session:
- from models.user import User
-
# Create admin user for authentication
user = User(username="admin", password="hashed", is_admin=True)
session.add(user)
@@ -322,8 +316,6 @@ class TestRevisionDeletionWithLines(DigiScriptTestCase):
def setUp(self):
super().setUp()
with self._app.get_db().sessionmaker() as session:
- from models.user import User
-
user = User(username="admin", password="hashed", is_admin=True)
session.add(user)
session.flush()
@@ -687,8 +679,6 @@ class TestScriptRevisionBranching(DigiScriptTestCase):
def setUp(self):
super().setUp()
with self._app.get_db().sessionmaker() as session:
- from models.user import User
-
user = User(username="admin", password="hashed", is_admin=True)
session.add(user)
session.flush()
@@ -861,7 +851,6 @@ def test_parent_revision_from_different_script(self):
show2 = Show(name="Second Show", script_mode=ShowScriptType.FULL)
session.add(show2)
session.flush()
- show2_id = show2.id
script2 = Script(show_id=show2.id)
session.add(script2)
@@ -907,8 +896,6 @@ class TestScriptRevisionBranchingWithLines(DigiScriptTestCase):
def setUp(self):
super().setUp()
with self._app.get_db().sessionmaker() as session:
- from models.user import User
-
user = User(username="admin", password="hashed", is_admin=True)
session.add(user)
session.flush()
@@ -1085,8 +1072,6 @@ class TestScriptRevisionDeletionTreeIntegrity(DigiScriptTestCase):
def setUp(self):
super().setUp()
with self._app.get_db().sessionmaker() as session:
- from models.user import User
-
user = User(username="admin", password="hashed", is_admin=True)
session.add(user)
session.flush()
diff --git a/server/test/controllers/api/show/script/test_script.py b/server/test/controllers/api/show/script/test_script.py
index c1d56b47..60ded342 100644
--- a/server/test/controllers/api/show/script/test_script.py
+++ b/server/test/controllers/api/show/script/test_script.py
@@ -1,21 +1,19 @@
-from test.conftest import DigiScriptTestCase
-
import tornado.escape
from sqlalchemy import select
from models.cue import Cue, CueAssociation, CueType
from models.script import (
- ScriptLineType,
Script,
ScriptCuts,
ScriptLine,
ScriptLinePart,
ScriptLineRevisionAssociation,
+ ScriptLineType,
ScriptRevision,
)
from models.show import Act, Character, Scene, Show, ShowScriptType
from models.user import User
-from rbac.role import Role
+from test.conftest import DigiScriptTestCase
class TestScriptController(DigiScriptTestCase):
diff --git a/server/test/controllers/api/show/script/test_stage_direction_styles.py b/server/test/controllers/api/show/script/test_stage_direction_styles.py
index 388496ea..0c8c70f3 100644
--- a/server/test/controllers/api/show/script/test_stage_direction_styles.py
+++ b/server/test/controllers/api/show/script/test_stage_direction_styles.py
@@ -1,5 +1,4 @@
import tornado.escape
-from sqlalchemy import select
from models.script import Script, ScriptRevision, StageDirectionStyle
from models.show import Show, ShowScriptType
diff --git a/server/test/controllers/api/show/session/test_assign_tags.py b/server/test/controllers/api/show/session/test_assign_tags.py
index 86dc257f..f9048514 100644
--- a/server/test/controllers/api/show/session/test_assign_tags.py
+++ b/server/test/controllers/api/show/session/test_assign_tags.py
@@ -1,8 +1,8 @@
"""Tests for Session Tag Assignment API controller."""
-from sqlalchemy import select
from tornado import escape
+from models.script import Script, ScriptRevision
from models.session import SessionTag, ShowSession
from models.show import Show, ShowScriptType
from models.user import User
@@ -21,6 +21,17 @@ def setUp(self):
session.flush()
self.show_id = show.id
+ script = Script(show_id=show.id)
+ session.add(script)
+ session.flush()
+
+ revision = ScriptRevision(
+ script_id=script.id, revision=1, description="Test Revision"
+ )
+ session.add(revision)
+ session.flush()
+ self.revision_id = revision.id
+
admin = User(username="admin", is_admin=True, password="test")
session.add(admin)
session.flush()
@@ -45,7 +56,9 @@ def create_tag(self, tag_name, colour="#FF0000"):
def create_session(self):
"""Helper to create a ShowSession."""
with self._app.get_db().sessionmaker() as session:
- show_session = ShowSession(show_id=self.show_id)
+ show_session = ShowSession(
+ show_id=self.show_id, script_revision_id=self.revision_id
+ )
session.add(show_session)
session.flush()
session_id = show_session.id
@@ -284,7 +297,19 @@ def test_patch_session_tags_session_from_different_show(self):
session.add(other_show)
session.flush()
- other_session = ShowSession(show_id=other_show.id)
+ other_script = Script(show_id=other_show.id)
+ session.add(other_script)
+ session.flush()
+
+ other_revision = ScriptRevision(
+ script_id=other_script.id, revision=1, description="Other Revision"
+ )
+ session.add(other_revision)
+ session.flush()
+
+ other_session = ShowSession(
+ show_id=other_show.id, script_revision_id=other_revision.id
+ )
session.add(other_session)
session.flush()
other_session_id = other_session.id
diff --git a/server/test/controllers/api/show/session/test_sessions.py b/server/test/controllers/api/show/session/test_sessions.py
index 47b7f9db..d8330506 100644
--- a/server/test/controllers/api/show/session/test_sessions.py
+++ b/server/test/controllers/api/show/session/test_sessions.py
@@ -1,6 +1,6 @@
import tornado.escape
-from sqlalchemy import select
+from models.script import Script, ScriptRevision
from models.session import ShowSession
from models.show import Show, ShowScriptType
from test.conftest import DigiScriptTestCase
@@ -17,6 +17,17 @@ def setUp(self):
session.add(show)
session.flush()
self.show_id = show.id
+
+ script = Script(show_id=show.id)
+ session.add(script)
+ session.flush()
+
+ revision = ScriptRevision(
+ script_id=script.id, revision=1, description="Test Revision"
+ )
+ session.add(revision)
+ session.flush()
+ self.revision_id = revision.id
session.commit()
self._app.digi_settings.settings["current_show"].set_value(self.show_id)
@@ -37,8 +48,16 @@ def test_get_sessions_with_data(self):
"""Test GET /api/v1/show/sessions with existing sessions."""
# Create test show sessions
with self._app.get_db().sessionmaker() as session:
- show_session1 = ShowSession(show_id=self.show_id, user_id=None)
- show_session2 = ShowSession(show_id=self.show_id, user_id=None)
+ show_session1 = ShowSession(
+ show_id=self.show_id,
+ script_revision_id=self.revision_id,
+ user_id=None,
+ )
+ show_session2 = ShowSession(
+ show_id=self.show_id,
+ script_revision_id=self.revision_id,
+ user_id=None,
+ )
session.add(show_session1)
session.add(show_session2)
session.commit()
diff --git a/server/test/controllers/api/show/session/test_tags.py b/server/test/controllers/api/show/session/test_tags.py
index 73fee5cb..a14d97c6 100644
--- a/server/test/controllers/api/show/session/test_tags.py
+++ b/server/test/controllers/api/show/session/test_tags.py
@@ -3,6 +3,7 @@
from sqlalchemy import insert, select
from tornado import escape
+from models.script import Script, ScriptRevision
from models.session import SessionTag, ShowSession, session_tag_association_table
from models.show import Show, ShowScriptType
from models.user import User
@@ -22,6 +23,17 @@ def setUp(self):
session.flush()
self.show_id = show.id
+ script = Script(show_id=show.id)
+ session.add(script)
+ session.flush()
+
+ revision = ScriptRevision(
+ script_id=script.id, revision=1, description="Test Revision"
+ )
+ session.add(revision)
+ session.flush()
+ self.revision_id = revision.id
+
# Create admin user
admin = User(username="admin", is_admin=True, password="test")
session.add(admin)
@@ -374,7 +386,9 @@ def test_delete_tag_cascade_associations(self):
# Create a ShowSession and associate the tag
with self._app.get_db().sessionmaker() as session:
- show_session = ShowSession(show_id=self.show_id)
+ show_session = ShowSession(
+ show_id=self.show_id, script_revision_id=self.revision_id
+ )
session.add(show_session)
session.flush()
session_id = show_session.id
diff --git a/server/test/controllers/api/show/test_characters.py b/server/test/controllers/api/show/test_characters.py
index 11adc725..0b2d213c 100644
--- a/server/test/controllers/api/show/test_characters.py
+++ b/server/test/controllers/api/show/test_characters.py
@@ -1,7 +1,7 @@
import tornado.escape
from models.script import Script, ScriptRevision
-from models.show import Character, Show, ShowScriptType
+from models.show import Show, ShowScriptType
from test.conftest import DigiScriptTestCase
diff --git a/server/test/controllers/api/show/test_cues.py b/server/test/controllers/api/show/test_cues.py
index e3e0652d..0eee54de 100644
--- a/server/test/controllers/api/show/test_cues.py
+++ b/server/test/controllers/api/show/test_cues.py
@@ -234,8 +234,6 @@ def test_post_cue_rejects_spacing_line(self):
# Verify no cue was created
with self._app.get_db().sessionmaker() as session:
- from models.cue import CueAssociation
-
cue_assocs = session.scalars(select(CueAssociation)).all()
self.assertEqual(
0,
@@ -266,8 +264,6 @@ def test_post_cue_allows_dialogue_line(self):
# Verify cue was created
with self._app.get_db().sessionmaker() as session:
- from models.cue import CueAssociation
-
cue_assocs = session.scalars(select(CueAssociation)).all()
self.assertEqual(
1,
diff --git a/server/test/controllers/api/show/test_microphones.py b/server/test/controllers/api/show/test_microphones.py
index 2b376b2e..c73641fe 100644
--- a/server/test/controllers/api/show/test_microphones.py
+++ b/server/test/controllers/api/show/test_microphones.py
@@ -1,5 +1,4 @@
import tornado.escape
-from sqlalchemy import select
from models.mics import Microphone
from models.show import Show, ShowScriptType
diff --git a/server/test/controllers/api/show/test_shows.py b/server/test/controllers/api/show/test_shows.py
index ffebf189..7d63946d 100644
--- a/server/test/controllers/api/show/test_shows.py
+++ b/server/test/controllers/api/show/test_shows.py
@@ -1,7 +1,9 @@
import tornado.escape
+from sqlalchemy import select
from models.script import Script, ScriptRevision
from models.show import Show, ShowScriptType
+from models.user import User
from test.conftest import DigiScriptTestCase
@@ -12,8 +14,6 @@ def setUp(self):
super().setUp()
# Create admin user and token for authenticated requests
with self._app.get_db().sessionmaker() as session:
- from models.user import User
-
admin = User(username="admin", is_admin=True, password="test")
session.add(admin)
session.flush()
@@ -91,8 +91,6 @@ def test_create_show_with_script_mode(self):
# Verify in database
with self._app.get_db().sessionmaker() as session:
- from sqlalchemy import select
-
show = session.scalar(select(Show).where(Show.name == "Test Show"))
self.assertIsNotNone(show)
self.assertEqual(ShowScriptType.FULL, show.script_mode)
diff --git a/server/test/controllers/api/test_auth.py b/server/test/controllers/api/test_auth.py
index 462107d9..417174b9 100644
--- a/server/test/controllers/api/test_auth.py
+++ b/server/test/controllers/api/test_auth.py
@@ -1,6 +1,7 @@
-from tornado import escape
from sqlalchemy import select
+from tornado import escape
+from models.show import Show, ShowScriptType
from models.user import User
from test.conftest import DigiScriptTestCase
@@ -376,7 +377,6 @@ def test_api_token_get(self):
headers={"Authorization": f"Bearer {token}"},
)
response_body = escape.json_decode(response.body)
- api_token = response_body["api_token"]
# Check that token exists (but can't retrieve it)
response = self.fetch(
@@ -409,8 +409,6 @@ def test_delete_user(self):
# Create a test show (required by @requires_show decorator)
with self._app.get_db().sessionmaker() as session:
- from models.show import Show, ShowScriptType
-
show = Show(name="Test Show", script_mode=ShowScriptType.FULL)
session.add(show)
session.flush()
@@ -439,8 +437,6 @@ def test_delete_user(self):
# Get the user ID
with self._app.get_db().sessionmaker() as session:
- from models.user import User
-
user = session.scalars(
select(User).where(User.username == "userToDelete")
).first()
@@ -461,8 +457,6 @@ def test_delete_user(self):
# Verify user was deleted
with self._app.get_db().sessionmaker() as session:
- from models.user import User
-
deleted_user = session.scalars(
select(User).where(User.id == user_id)
).first()
@@ -487,8 +481,6 @@ def test_get_users(self):
# Create a test show (required by @requires_show decorator)
with self._app.get_db().sessionmaker() as session:
- from models.show import Show, ShowScriptType
-
show = Show(name="Test Show", script_mode=ShowScriptType.FULL)
session.add(show)
session.flush()
diff --git a/server/test/controllers/api/test_rbac.py b/server/test/controllers/api/test_rbac.py
index 9fb9feef..d99642b2 100644
--- a/server/test/controllers/api/test_rbac.py
+++ b/server/test/controllers/api/test_rbac.py
@@ -1,5 +1,4 @@
import tornado.escape
-from sqlalchemy import select
from models.script import Script
from models.show import Show, ShowScriptType
diff --git a/server/test/controllers/api/test_settings.py b/server/test/controllers/api/test_settings.py
index 43d5db8a..c0886862 100644
--- a/server/test/controllers/api/test_settings.py
+++ b/server/test/controllers/api/test_settings.py
@@ -1,7 +1,6 @@
from tornado.testing import gen_test
from digi_server.logger import get_logger
-
from test.conftest import DigiScriptTestCase
diff --git a/server/test/controllers/api/test_websocket.py b/server/test/controllers/api/test_websocket.py
index 58adbce4..6c38c626 100644
--- a/server/test/controllers/api/test_websocket.py
+++ b/server/test/controllers/api/test_websocket.py
@@ -1,5 +1,4 @@
import tornado.escape
-from sqlalchemy import select
from models.session import Session
from test.conftest import DigiScriptTestCase
diff --git a/server/test/controllers/api/user/test_overrides.py b/server/test/controllers/api/user/test_overrides.py
index 53cfd0bc..1ad61024 100644
--- a/server/test/controllers/api/user/test_overrides.py
+++ b/server/test/controllers/api/user/test_overrides.py
@@ -1,6 +1,5 @@
"""Tests for /api/v1/user/settings/stage_direction_overrides endpoints."""
-import pytest
import tornado.escape
from sqlalchemy import select
diff --git a/server/test/controllers/test_ws_controller.py b/server/test/controllers/test_ws_controller.py
index 47cddb7d..44f9c789 100644
--- a/server/test/controllers/test_ws_controller.py
+++ b/server/test/controllers/test_ws_controller.py
@@ -10,6 +10,7 @@
from tornado.testing import gen_test
from tornado.websocket import websocket_connect
+from models.script import Script, ScriptRevision
from models.session import Session, ShowSession
from models.show import Show, ShowScriptType
from models.user import User
@@ -91,7 +92,7 @@ async def test_request_script_edit_with_existing_editor(self):
# Receive initial messages
msg1 = await ws.read_message()
- msg2 = await ws.read_message()
+ await ws.read_message() # Consume GET_SETTINGS
# Send REQUEST_SCRIPT_EDIT message
await ws.write_message(json.dumps({"OP": "REQUEST_SCRIPT_EDIT", "DATA": {}}))
@@ -128,6 +129,17 @@ async def test_websocket_close_elects_leader(self):
session.add(show)
session.flush()
show_id = show.id
+
+ script = Script(show_id=show.id)
+ session.add(script)
+ session.flush()
+
+ revision = ScriptRevision(
+ script_id=script.id, revision=1, description="Test Revision"
+ )
+ session.add(revision)
+ session.flush()
+ revision_id = revision.id
session.commit()
self._app.digi_settings.settings["current_show"].set_value(show_id)
@@ -159,6 +171,7 @@ async def test_websocket_close_elects_leader(self):
# The live_session relationship is auto-populated via client_internal_id
show_session = ShowSession(
show_id=show_id,
+ script_revision_id=revision_id,
user_id=self.user_id,
client_internal_id=ws1_uuid,
)
@@ -200,6 +213,17 @@ async def test_websocket_close_no_next_leader(self):
session.add(show)
session.flush()
show_id = show.id
+
+ script = Script(show_id=show.id)
+ session.add(script)
+ session.flush()
+
+ revision = ScriptRevision(
+ script_id=script.id, revision=1, description="Test Revision"
+ )
+ session.add(revision)
+ session.flush()
+ revision_id = revision.id
session.commit()
self._app.digi_settings.settings["current_show"].set_value(show_id)
@@ -227,6 +251,7 @@ async def test_websocket_close_no_next_leader(self):
# Create show session - live_session relationship auto-populated
show_session = ShowSession(
show_id=show_id,
+ script_revision_id=revision_id,
user_id=self.user_id,
client_internal_id=ws1_uuid,
)
diff --git a/server/test/digi_server/test_digi_server.py b/server/test/digi_server/test_digi_server.py
index 65a19c29..0572580c 100644
--- a/server/test/digi_server/test_digi_server.py
+++ b/server/test/digi_server/test_digi_server.py
@@ -1,9 +1,9 @@
import tornado.escape
-from tornado.testing import gen_test
from sqlalchemy import func, select
+from tornado.testing import gen_test
-from models.session import ShowSession
-from models.show import Show
+from models.session import Session
+from models.settings import SystemSettings
from models.user import User
from test.conftest import DigiScriptTestCase
@@ -90,8 +90,6 @@ def test_initialization_clears_sessions(self):
The initialization runs in setUp() and clears the sessions table.
"""
# After initialization, sessions table should be empty
- from models.session import Session
-
with self._app.get_db().sessionmaker() as session:
count = session.scalar(select(func.count()).select_from(Session))
self.assertEqual(0, count)
@@ -105,8 +103,6 @@ def test_configure_jwt_creates_secret_if_missing(self):
The JWT service is configured in __init__, which should create
a secret if one doesn't exist.
"""
- from models.settings import SystemSettings
-
# The JWT service is already configured in setUp via __init__
# Verify a secret was created in the database
with self._app.get_db().sessionmaker() as session:
diff --git a/server/test/models/test_script.py b/server/test/models/test_script.py
index 174c17df..da033d92 100644
--- a/server/test/models/test_script.py
+++ b/server/test/models/test_script.py
@@ -1,10 +1,10 @@
import os
import tempfile
-from tornado.testing import gen_test
from sqlalchemy import func, select
+from tornado.testing import gen_test
-from models.script import CompiledScript, ScriptRevision, StageDirectionStyle, Script
+from models.script import CompiledScript, Script, ScriptRevision, StageDirectionStyle
from models.show import Show, ShowScriptType
from models.user import User, UserOverrides
from test.conftest import DigiScriptTestCase
diff --git a/server/test/utils/show/test_line_type_validator.py b/server/test/utils/show/test_line_type_validator.py
index 2c3e8709..4d296f4d 100644
--- a/server/test/utils/show/test_line_type_validator.py
+++ b/server/test/utils/show/test_line_type_validator.py
@@ -5,16 +5,16 @@
and the LineTypeValidatorRegistry.
"""
-import pytest
from unittest.mock import MagicMock
+import pytest
+
from models.script import ScriptLineType
from models.show import ShowScriptType
from utils.show.line_type_validator import (
DialogueValidator,
EmptyLineValidator,
LineTypeValidatorRegistry,
- LineTypeValidationResult,
StageDirectionValidator,
)
diff --git a/server/test/utils/show/test_mic_assignment.py b/server/test/utils/show/test_mic_assignment.py
index f2853a31..c84185c2 100644
--- a/server/test/utils/show/test_mic_assignment.py
+++ b/server/test/utils/show/test_mic_assignment.py
@@ -5,20 +5,21 @@
preservation, over-capacity handling, and edge cases.
"""
-import pytest
-from unittest.mock import MagicMock
from collections import defaultdict
+from unittest.mock import MagicMock
+
+import pytest
from models.script import ScriptLineType
from utils.show.mic_assignment import (
SceneMetadata,
- swap_cost,
- calculate_swap_cost_with_cast,
- collect_character_appearances,
- find_best_mic,
_mic_already_used_in_scene,
_mic_manually_allocated_to_character,
_mic_used_by_character_in_new_allocations,
+ calculate_swap_cost_with_cast,
+ collect_character_appearances,
+ find_best_mic,
+ swap_cost,
)
From 1f274cfcac0bb9c7bb2949ee0ab2f5a0d96b0427 Mon Sep 17 00:00:00 2001
From: Tim Bradgate
Date: Sat, 10 Jan 2026 00:42:42 +0000
Subject: [PATCH 02/16] Allow users to choose script text alignment (#833)
---
client/src/constants/textAlignment.js | 17 +++++
client/src/mixins/scriptDisplayMixin.js | 66 +++++++++++++------
.../show/config/cues/ScriptLineCueEditor.vue | 56 +++-------------
.../show/config/script/ScriptLineViewer.vue | 43 ++++--------
.../show/live/ScriptLineViewer.vue | 22 +++----
.../vue_components/user/settings/Settings.vue | 24 +++++++
..._add_script_text_alignment_user_setting.py | 37 +++++++++++
...backfill_and_set_script_text_alignment_.py | 40 +++++++++++
server/models/user.py | 49 +++++++++++++-
9 files changed, 242 insertions(+), 112 deletions(-)
create mode 100644 client/src/constants/textAlignment.js
create mode 100644 server/alembic_config/versions/859636b5ffbb_add_script_text_alignment_user_setting.py
create mode 100644 server/alembic_config/versions/b5a760d2ee49_backfill_and_set_script_text_alignment_.py
diff --git a/client/src/constants/textAlignment.js b/client/src/constants/textAlignment.js
new file mode 100644
index 00000000..084aec71
--- /dev/null
+++ b/client/src/constants/textAlignment.js
@@ -0,0 +1,17 @@
+/**
+ * Text alignment enum constants matching backend TextAlignment IntEnum
+ */
+export const TEXT_ALIGNMENT = {
+ LEFT: 1,
+ CENTER: 2,
+ RIGHT: 3,
+};
+
+/**
+ * Map TEXT_ALIGNMENT enum values to CSS text-align values
+ */
+export const TEXT_ALIGNMENT_CSS = {
+ [TEXT_ALIGNMENT.LEFT]: 'left',
+ [TEXT_ALIGNMENT.CENTER]: 'center',
+ [TEXT_ALIGNMENT.RIGHT]: 'right',
+};
\ No newline at end of file
diff --git a/client/src/mixins/scriptDisplayMixin.js b/client/src/mixins/scriptDisplayMixin.js
index bb411325..011bccab 100644
--- a/client/src/mixins/scriptDisplayMixin.js
+++ b/client/src/mixins/scriptDisplayMixin.js
@@ -1,14 +1,21 @@
+import { mapGetters } from 'vuex';
import { LINE_TYPES } from '@/constants/lineTypes';
+import { TEXT_ALIGNMENT_CSS } from '@/constants/textAlignment';
/**
* Shared mixin for script display presentation logic.
* Handles stage directions, act/scene labels, intervals, and viewport tracking.
*
* This mixin assumes the component has:
- * - Props: line, lineIndex, previousLine, previousLineIndex, acts, scenes,
+ * - Props: line, lineIndex, previousLine, acts, scenes,
* stageDirectionStyles, stageDirectionStyleOverrides
- * - Refs: lineContainer
- * - Methods: isWholeLineCut, getPreviousLineForIndex (from scriptNavigationMixin)
+ * - Optional props: previousLineIndex (required for interval/cut-aware features)
+ * - Optional refs: lineContainer (required for viewport tracking in live view)
+ * - Optional methods: isWholeLineCut, getPreviousLineForIndex (from scriptNavigationMixin,
+ * required for interval/cut-aware features)
+ *
+ * Components using only the alignment/styling features (headingStyle, dialogueStyle,
+ * scriptTextAlign, stageDirectionStyling) do not need the optional dependencies.
*/
export default {
data() {
@@ -77,27 +84,48 @@ export default {
}
return style;
},
+ scriptTextAlign() {
+ const alignment = this.USER_SETTINGS.script_text_alignment || 2;
+ return TEXT_ALIGNMENT_CSS[alignment] || 'center';
+ },
+ headingStyle() {
+ return { textAlign: this.scriptTextAlign };
+ },
+ dialogueStyle() {
+ return { textAlign: this.scriptTextAlign };
+ },
+ needsHeadingsAny() {
+ return this.needsHeadings.some((x) => (x === true));
+ },
+ needsHeadingsAll() {
+ return this.needsHeadings.every((x) => (x === true));
+ },
+ ...mapGetters(['USER_SETTINGS']),
},
mounted() {
-
- this.observer = new MutationObserver((mutations) => {
- for (const m of mutations) {
- const newValue = m.target.getAttribute(m.attributeName);
- this.$nextTick(() => {
- this.onClassChange(newValue, m.oldValue);
- });
- }
- });
-
+ // Only set up viewport observer if lineContainer ref exists
+ // (e.g., live view needs this, but editor view does not)
+ if (this.$refs.lineContainer) {
+ this.observer = new MutationObserver((mutations) => {
+ for (const m of mutations) {
+ const newValue = m.target.getAttribute(m.attributeName);
+ this.$nextTick(() => {
+ this.onClassChange(newValue, m.oldValue);
+ });
+ }
+ });
- this.observer.observe(this.$refs.lineContainer, {
- attributes: true,
- attributeOldValue: true,
- attributeFilter: ['class'],
- });
+ this.observer.observe(this.$refs.lineContainer, {
+ attributes: true,
+ attributeOldValue: true,
+ attributeFilter: ['class'],
+ });
+ }
},
destroyed() {
- this.observer.disconnect();
+ if (this.observer) {
+ this.observer.disconnect();
+ }
},
methods: {
onClassChange(classAttrValue, oldClassAttrValue) {
diff --git a/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue b/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue
index 26d35c4c..42c32841 100644
--- a/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue
+++ b/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue
@@ -5,7 +5,7 @@
style="margin: 0; padding: 0 0 .2rem;"
fluid
>
-
+
{{ actLabel }} - {{ sceneLabel }}
@@ -43,7 +43,7 @@
@@ -65,7 +65,7 @@
{{ characters.find((char) => (char.id === part.character_id)).name }}
@@ -332,9 +332,11 @@ import { contrastColor } from 'contrast-color';
import log from 'loglevel';
import { LINE_TYPES } from '@/constants/lineTypes';
import { isWholeLineCut as isWholeLineCutUtil } from '@/js/scriptUtils';
+import scriptDisplayMixin from '@/mixins/scriptDisplayMixin';
export default {
name: 'ScriptLineCueEditor',
+ mixins: [scriptDisplayMixin],
props: {
line: {
required: true,
@@ -467,51 +469,13 @@ export default {
}, this);
return ret;
},
- needsHeadingsAny() {
- return this.needsHeadings.some((x) => (x === true));
- },
- needsActSceneLabel() {
+ needsActSceneLabelSimple() {
if (this.previousLine == null) {
return true;
}
return !(this.previousLine.act_id === this.line.act_id
&& this.previousLine.scene_id === this.line.scene_id);
},
- actLabel() {
- return this.acts.find((act) => (act.id === this.line.act_id)).name;
- },
- sceneLabel() {
- return this.scenes.find((scene) => (scene.id === this.line.scene_id)).name;
- },
- stageDirectionStyle() {
- const sdStyle = this.stageDirectionStyles.find(
- (style) => (style.id === this.line.stage_direction_style_id),
- );
- const override = this.stageDirectionStyleOverrides
- .find((elem) => elem.settings.id === sdStyle.id);
- if (this.line.line_type === LINE_TYPES.STAGE_DIRECTION) {
- return override ? override.settings : sdStyle;
- }
- return null;
- },
- stageDirectionStyling() {
- if (this.line.stage_direction_style_id == null || this.stageDirectionStyle == null) {
- return {
- 'background-color': 'darkslateblue',
- 'font-style': 'italic',
- };
- }
- const style = {
- 'font-weight': this.stageDirectionStyle.bold ? 'bold' : 'normal',
- 'font-style': this.stageDirectionStyle.italic ? 'italic' : 'normal',
- 'text-decoration-line': this.stageDirectionStyle.underline ? 'underline' : 'none',
- color: this.stageDirectionStyle.text_colour,
- };
- if (this.stageDirectionStyle.enable_background_colour) {
- style['background-color'] = this.stageDirectionStyle.background_colour;
- }
- return style;
- },
flatScriptCues() {
return Object.keys(this.SCRIPT_CUES).map((key) => this.SCRIPT_CUES[key]).flat();
},
diff --git a/client/src/vue_components/show/config/script/ScriptLineViewer.vue b/client/src/vue_components/show/config/script/ScriptLineViewer.vue
index 7f26664b..d7c173b4 100644
--- a/client/src/vue_components/show/config/script/ScriptLineViewer.vue
+++ b/client/src/vue_components/show/config/script/ScriptLineViewer.vue
@@ -7,7 +7,7 @@
>
{{ actLabel }}
@@ -15,7 +15,7 @@
{{ sceneLabel }}
@@ -27,7 +27,7 @@
@@ -46,7 +46,7 @@
import { mapGetters } from 'vuex';
import { LINE_TYPES } from '@/constants/lineTypes';
+import scriptDisplayMixin from '@/mixins/scriptDisplayMixin';
export default {
name: 'ScriptLineViewer',
+ mixins: [scriptDisplayMixin],
events: ['editLine', 'cutLinePart', 'insertDialogue', 'insertStageDirection', 'insertCueLine', 'insertSpacing', 'deleteLine'],
props: {
line: {
@@ -278,37 +280,14 @@ export default {
}, this);
return ret;
},
- needsHeadingsAny() {
- return this.needsHeadings.some((x) => (x === true));
- },
- needsHeadingsAll() {
- return this.needsHeadings.every((x) => (x === true));
- },
- needsActSceneLabel() {
+ needsActSceneLabelSimple() {
if (this.previousLine == null) {
return true;
}
return !(this.previousLine.act_id === this.line.act_id
&& this.previousLine.scene_id === this.line.scene_id);
},
- actLabel() {
- return this.acts.find((act) => (act.id === this.line.act_id)).name;
- },
- sceneLabel() {
- return this.scenes.find((scene) => (scene.id === this.line.scene_id)).name;
- },
- stageDirectionStyle() {
- const sdStyle = this.stageDirectionStyles.find(
- (style) => (style.id === this.line.stage_direction_style_id),
- );
- const override = this.stageDirectionStyleOverrides
- .find((elem) => elem.settings.id === sdStyle.id);
- if (this.line.line_type === LINE_TYPES.STAGE_DIRECTION) {
- return override ? override.settings : sdStyle;
- }
- return null;
- },
- stageDirectionStyling() {
+ stageDirectionStylingWithCuts() {
if (this.line.stage_direction_style_id == null || this.stageDirectionStyle == null) {
const style = {
'background-color': 'darkslateblue',
diff --git a/client/src/vue_components/show/live/ScriptLineViewer.vue b/client/src/vue_components/show/live/ScriptLineViewer.vue
index ef870b5f..a0721f11 100644
--- a/client/src/vue_components/show/live/ScriptLineViewer.vue
+++ b/client/src/vue_components/show/live/ScriptLineViewer.vue
@@ -100,7 +100,7 @@
@@ -119,7 +119,7 @@
@@ -237,7 +237,7 @@
@@ -369,12 +369,6 @@ export default {
};
},
computed: {
- needsHeadingsAny() {
- return this.needsHeadings.some((x) => (x === true));
- },
- needsHeadingsAll() {
- return this.needsHeadings.every((x) => (x === true));
- },
isFirstRowIntervalBanner() {
return this.needsIntervalBanner;
},
diff --git a/client/src/vue_components/user/settings/Settings.vue b/client/src/vue_components/user/settings/Settings.vue
index 0bb1b1be..9d6a8047 100644
--- a/client/src/vue_components/user/settings/Settings.vue
+++ b/client/src/vue_components/user/settings/Settings.vue
@@ -50,6 +50,19 @@
:switch="true"
/>
+
+
+
None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("user_settings", schema=None) as batch_op:
+ batch_op.add_column(
+ sa.Column("script_text_alignment", sa.Integer(), nullable=True)
+ )
+
+ # ### end Alembic commands ###
+
+
+def downgrade() -> None:
+ # ### commands auto generated by Alembic - please adjust! ###
+ with op.batch_alter_table("user_settings", schema=None) as batch_op:
+ batch_op.drop_column("script_text_alignment")
+
+ # ### end Alembic commands ###
diff --git a/server/alembic_config/versions/b5a760d2ee49_backfill_and_set_script_text_alignment_.py b/server/alembic_config/versions/b5a760d2ee49_backfill_and_set_script_text_alignment_.py
new file mode 100644
index 00000000..8a0df7a5
--- /dev/null
+++ b/server/alembic_config/versions/b5a760d2ee49_backfill_and_set_script_text_alignment_.py
@@ -0,0 +1,40 @@
+"""backfill_and_set_script_text_alignment_not_null
+
+Revision ID: b5a760d2ee49
+Revises: 859636b5ffbb
+Create Date: 2026-01-09 12:02:21.850022
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+
+# revision identifiers, used by Alembic.
+revision: str = "b5a760d2ee49"
+down_revision: Union[str, None] = "859636b5ffbb"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Backfill NULL values to 2 (CENTER) for existing users
+ op.execute(
+ "UPDATE user_settings SET script_text_alignment = 2 WHERE script_text_alignment IS NULL"
+ )
+
+ # Make column non-nullable
+ with op.batch_alter_table("user_settings", schema=None) as batch_op:
+ batch_op.alter_column(
+ "script_text_alignment", existing_type=sa.Integer(), nullable=False
+ )
+
+
+def downgrade() -> None:
+ # Make column nullable again
+ with op.batch_alter_table("user_settings", schema=None) as batch_op:
+ batch_op.alter_column(
+ "script_text_alignment", existing_type=sa.Integer(), nullable=True
+ )
diff --git a/server/models/user.py b/server/models/user.py
index 4dd28ed7..0e5ba9e6 100644
--- a/server/models/user.py
+++ b/server/models/user.py
@@ -1,9 +1,10 @@
import datetime
+import enum
import json
from functools import partial
from typing import TYPE_CHECKING, List, Union
-from sqlalchemy import ForeignKey, Text, select
+from sqlalchemy import ForeignKey, Integer, Text, TypeDecorator, select
from sqlalchemy.orm import Mapped, mapped_column, relationship
from models.models import db
@@ -14,6 +15,49 @@
from models.session import Session
+class TextAlignment(enum.IntEnum):
+ """Text alignment options for script display"""
+
+ LEFT = 1
+ CENTER = 2
+ RIGHT = 3
+
+
+class TextAlignmentCol(TypeDecorator):
+ """SQLAlchemy type decorator for TextAlignment enum"""
+
+ impl = Integer
+ cache_ok = True
+
+ def process_bind_param(self, value, dialect):
+ if value is None:
+ return None
+ # Convert integers to TextAlignment enum (e.g., from API requests)
+ if isinstance(value, int):
+ try:
+ value = TextAlignment(value)
+ except ValueError:
+ raise Exception(
+ f"TextAlignmentCol received invalid integer {value}. "
+ f"Valid values are: {[e.value for e in TextAlignment]}"
+ )
+ if not isinstance(value, TextAlignment):
+ raise Exception(
+ f"TextAlignmentCol data type is incorrect. Got {type(value)} but should be TextAlignment or int"
+ )
+ return value.value
+
+ def process_literal_param(self, value, dialect):
+ return self.process_bind_param(value, dialect)
+
+ @property
+ def python_type(self):
+ return TextAlignment
+
+ def process_result_value(self, value, dialect):
+ return TextAlignment(value)
+
+
class User(db.Model):
__tablename__ = "user"
@@ -35,6 +79,9 @@ class UserSettings(db.Model):
enable_script_auto_save: Mapped[bool | None] = mapped_column(default=True)
script_auto_save_interval: Mapped[int | None] = mapped_column(default=10)
cue_position_right: Mapped[bool | None] = mapped_column(default=False)
+ script_text_alignment: Mapped[TextAlignment] = mapped_column(
+ TextAlignmentCol, default=TextAlignment.CENTER
+ )
# Hidden Properties (None user editable, marked with _)
# Make sure to also mark these as hidden in the Schema for this in schemas/schemas.py
From bb0b6df6dd34592a40babd33c2f4d26aeaeabf95 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Sat, 10 Jan 2026 00:47:36 +0000
Subject: [PATCH 03/16] Bump ruff from 0.14.10 to 0.14.11 in /server (#832)
Bumps [ruff](https://github.com/astral-sh/ruff) from 0.14.10 to 0.14.11.
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.14.10...0.14.11)
---
updated-dependencies:
- dependency-name: ruff
dependency-version: 0.14.11
dependency-type: direct:production
update-type: version-update:semver-patch
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
server/test_requirements.txt | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/server/test_requirements.txt b/server/test_requirements.txt
index e11a65ba..a39aa96e 100644
--- a/server/test_requirements.txt
+++ b/server/test_requirements.txt
@@ -1,3 +1,3 @@
pytest<9.1
pytest-asyncio>=1.3.0
-ruff==0.14.10
\ No newline at end of file
+ruff==0.14.11
\ No newline at end of file
From de349bb0773fd6cef104722cc049d9047a6bef9d Mon Sep 17 00:00:00 2001
From: Tim Bradgate
Date: Sat, 10 Jan 2026 18:20:27 +0000
Subject: [PATCH 04/16] Extract shared stats table logic into statsTableMixin
(#835)
Refactored three stats table components (CastLineStats, CharacterLineStats,
CueCountStats) to use a shared mixin for common act/scene traversal logic.
Changes:
- Created statsTableMixin.js with sortedActs, sortedScenes computed properties
- Extracted numScenesPerAct, getHeaderName, getCellName helper methods
- Centralized act/scene data fetching in mounted lifecycle hook
- Reduced code duplication by ~150 lines across three components
Each component retains its unique stats fetching and display logic while
sharing the common act/scene hierarchy traversal pattern.
Co-authored-by: Claude Sonnet 4.5
---
client/src/mixins/statsTableMixin.js | 102 ++++++++++++++++++
.../show/config/cast/CastLineStats.vue | 60 +----------
.../config/characters/CharacterLineStats.vue | 60 +----------
.../show/config/cues/CueCountStats.vue | 60 +----------
4 files changed, 117 insertions(+), 165 deletions(-)
create mode 100644 client/src/mixins/statsTableMixin.js
diff --git a/client/src/mixins/statsTableMixin.js b/client/src/mixins/statsTableMixin.js
new file mode 100644
index 00000000..225c5673
--- /dev/null
+++ b/client/src/mixins/statsTableMixin.js
@@ -0,0 +1,102 @@
+import { mapActions, mapGetters } from 'vuex';
+
+/**
+ * Shared mixin for stats table components that display data organized by acts and scenes.
+ * Provides common logic for traversing and displaying act/scene hierarchies in table format.
+ *
+ * This mixin assumes the component has:
+ * - A Vuex store with ACT_BY_ID, SCENE_BY_ID, and CURRENT_SHOW getters
+ * - A Vuex store with GET_ACT_LIST and GET_SCENE_LIST actions
+ * - A `loaded` boolean in the component's data (for loading state)
+ * - A `getStats()` method that fetches component-specific statistics
+ *
+ * Components using this mixin should implement:
+ * - data() with `loaded: false` and component-specific stats object
+ * - methods.getStats() - async method to fetch stats from API
+ * - computed.tableData - component-specific data formatting
+ * - computed.tableFields - component-specific field names (can use this.sortedScenes)
+ */
+export default {
+ computed: {
+ /**
+ * Returns acts in order by following the linked list structure.
+ * Starts from first_act_id and follows next_act references.
+ */
+ sortedActs() {
+ if (this.CURRENT_SHOW.first_act_id == null) {
+ return [];
+ }
+ let currentAct = this.ACT_BY_ID(this.CURRENT_SHOW.first_act_id);
+ if (currentAct == null) {
+ return [];
+ }
+ const acts = [];
+ while (currentAct != null) {
+ acts.push(currentAct);
+ currentAct = this.ACT_BY_ID(currentAct.next_act);
+ }
+ return acts;
+ },
+
+ /**
+ * Returns scenes in order by following the linked list structure.
+ * Iterates through acts, then through scenes within each act.
+ */
+ sortedScenes() {
+ if (this.CURRENT_SHOW.first_act_id == null) {
+ return [];
+ }
+
+ let currentAct = this.ACT_BY_ID(this.CURRENT_SHOW.first_act_id);
+ if (currentAct == null || currentAct.first_scene == null) {
+ return [];
+ }
+
+ const scenes = [];
+ while (currentAct != null) {
+ let currentScene = this.SCENE_BY_ID(currentAct.first_scene);
+ while (currentScene != null) {
+ scenes.push(currentScene);
+ currentScene = this.SCENE_BY_ID(currentScene.next_scene);
+ }
+ currentAct = this.ACT_BY_ID(currentAct.next_act);
+ }
+ return scenes;
+ },
+ ...mapGetters(['ACT_BY_ID', 'SCENE_BY_ID', 'CURRENT_SHOW']),
+ },
+
+ async mounted() {
+ await this.GET_ACT_LIST();
+ await this.GET_SCENE_LIST();
+ await this.getStats();
+ this.loaded = true;
+ },
+
+ methods: {
+ /**
+ * Counts how many scenes belong to a given act.
+ * Used for table header colspan calculations.
+ */
+ numScenesPerAct(actId) {
+ return this.sortedScenes.filter((scene) => scene.act === actId).length;
+ },
+
+ /**
+ * Generates the slot name for a scene's table header.
+ * Used in template #[getHeaderName(scene.id)] syntax.
+ */
+ getHeaderName(sceneId) {
+ return `head(${sceneId})`;
+ },
+
+ /**
+ * Generates the slot name for a scene's table cell.
+ * Used in template #[getCellName(scene.id)] syntax.
+ */
+ getCellName(sceneId) {
+ return `cell(${sceneId})`;
+ },
+ ...mapActions(['GET_ACT_LIST', 'GET_SCENE_LIST']),
+ },
+};
diff --git a/client/src/vue_components/show/config/cast/CastLineStats.vue b/client/src/vue_components/show/config/cast/CastLineStats.vue
index bfbfc66a..9116007f 100644
--- a/client/src/vue_components/show/config/cast/CastLineStats.vue
+++ b/client/src/vue_components/show/config/cast/CastLineStats.vue
@@ -68,12 +68,14 @@
diff --git a/client/src/vue_components/show/config/characters/CharacterLineStats.vue b/client/src/vue_components/show/config/characters/CharacterLineStats.vue
index 50e7203c..1aa9e36f 100644
--- a/client/src/vue_components/show/config/characters/CharacterLineStats.vue
+++ b/client/src/vue_components/show/config/characters/CharacterLineStats.vue
@@ -68,12 +68,14 @@
diff --git a/client/src/vue_components/show/config/cues/CueCountStats.vue b/client/src/vue_components/show/config/cues/CueCountStats.vue
index a34f321c..0eed9e53 100644
--- a/client/src/vue_components/show/config/cues/CueCountStats.vue
+++ b/client/src/vue_components/show/config/cues/CueCountStats.vue
@@ -68,12 +68,14 @@
From cd99b25230a58a2371426fdb403687359794b68c Mon Sep 17 00:00:00 2001
From: Tim Bradgate
Date: Sun, 11 Jan 2026 01:34:11 +0000
Subject: [PATCH 05/16] Refactor script revisions tab into separate component
---
client/src/views/show/config/ConfigScript.vue | 459 +----------------
.../show/config/script/ScriptRevisions.vue | 468 ++++++++++++++++++
2 files changed, 472 insertions(+), 455 deletions(-)
create mode 100644 client/src/vue_components/show/config/script/ScriptRevisions.vue
diff --git a/client/src/views/show/config/ConfigScript.vue b/client/src/views/show/config/ConfigScript.vue
index eb3cdac5..fc91e740 100644
--- a/client/src/views/show/config/ConfigScript.vue
+++ b/client/src/views/show/config/ConfigScript.vue
@@ -10,115 +10,7 @@
title="Revisions"
active
>
-
-
-
-
-
- Revision Branch Graph
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Load
-
-
-
-
-
- {{
- SCRIPT_REVISIONS.find((rev) => (
- rev.id === data.item.previous_revision_id)).revision
- }}
-
-
- N/A
-
-
-
-
-
- Edit
-
-
- Delete
-
-
-
-
-
-
-
- New Revision
-
-
-
-
-
-
-
-
-
-
-
+
@@ -129,367 +21,24 @@
-
-
-
- This will create a new revision of the script based on the current revision, and set it
- as the new current revision.
-
-
-
-
-
-
- This is a required field.
-
-
-
-
-
-
-
-
-
-
-
-
- This will create a new revision based on revision {{ branchFormState.sourceRevision }}
- (current revision) and set it as the new current revision.
-
-
- This will create a new branch from revision {{ branchFormState.sourceRevision }}.
- The new revision will NOT be set as current.
-
-
-
-
-
-
- This is a required field.
-
-
-
-
diff --git a/client/src/vue_components/show/config/script/ScriptRevisions.vue b/client/src/vue_components/show/config/script/ScriptRevisions.vue
new file mode 100644
index 00000000..b2d0bc6f
--- /dev/null
+++ b/client/src/vue_components/show/config/script/ScriptRevisions.vue
@@ -0,0 +1,468 @@
+
+
+
+
+
+
+ Revision Branch Graph
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Load
+
+
+
+
+
+ {{
+ SCRIPT_REVISIONS.find((rev) => (
+ rev.id === data.item.previous_revision_id)).revision
+ }}
+
+
+ N/A
+
+
+
+
+
+ Edit
+
+
+ Delete
+
+
+
+
+
+
+
+ New Revision
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ This will create a new revision of the script based on the current revision, and set it
+ as the new current revision.
+
+
+
+
+
+
+ This is a required field.
+
+
+
+
+
+
+
+
+
+
+
+
+ This will create a new revision based on revision {{ branchFormState.sourceRevision }}
+ (current revision) and set it as the new current revision.
+
+
+ This will create a new branch from revision {{ branchFormState.sourceRevision }}.
+ The new revision will NOT be set as current.
+
+
+
+
+
+
+ This is a required field.
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
From b44d736563248209ffa6dc18d65cb817bda706d2 Mon Sep 17 00:00:00 2001
From: Tim Bradgate
Date: Sun, 11 Jan 2026 17:55:46 +0000
Subject: [PATCH 06/16] Improved compiled scripts user experience (#836)
* Add controller to list compiled scripts
* Cleanup compiled scripts on app startup
* Add front end for managing compiled scripts
---
client/src/router/index.js | 6 +
client/src/store/modules/script.js | 21 +++
client/src/views/show/ShowConfigView.vue | 10 ++
client/src/views/show/config/ConfigScript.vue | 11 +-
.../show/config/ConfigScriptRevisions.vue | 60 +++++++
.../show/config/script/CompiledScripts.vue | 160 ++++++++++++++++++
.../show/config/script/ScriptEditor.vue | 17 +-
.../show/config/script/ScriptRevisions.vue | 5 +-
.../controllers/api/show/script/compiled.py | 127 ++++++++++++++
server/digi_server/app_server.py | 48 +++++-
server/models/script.py | 2 +
server/schemas/schemas.py | 9 +
12 files changed, 461 insertions(+), 15 deletions(-)
create mode 100644 client/src/views/show/config/ConfigScriptRevisions.vue
create mode 100644 client/src/vue_components/show/config/script/CompiledScripts.vue
create mode 100644 server/controllers/api/show/script/compiled.py
diff --git a/client/src/router/index.js b/client/src/router/index.js
index 3a730aad..34f445fc 100644
--- a/client/src/router/index.js
+++ b/client/src/router/index.js
@@ -81,6 +81,12 @@ const routes = [
component: () => import('../views/show/config/ConfigScript.vue'),
meta: { requiresAuth: true, requiresShowAccess: true },
},
+ {
+ name: 'show-config-script-revisions',
+ path: 'script-revisions',
+ component: () => import('../views/show/config/ConfigScriptRevisions.vue'),
+ meta: { requiresAuth: true, requiresShowAccess: true },
+ },
{
name: 'show-sessions',
path: 'sessions',
diff --git a/client/src/store/modules/script.js b/client/src/store/modules/script.js
index 34e00632..cc15ee34 100644
--- a/client/src/store/modules/script.js
+++ b/client/src/store/modules/script.js
@@ -11,6 +11,7 @@ export default {
cues: {},
cuts: [],
stageDirectionStyles: [],
+ compiledScripts: [],
},
mutations: {
SET_REVISIONS(state, revisions) {
@@ -31,6 +32,9 @@ export default {
SET_STAGE_DIRECTION_STYLES(state, styles) {
state.stageDirectionStyles = styles;
},
+ SET_COMPILED_SCRIPTS(state, compiledScripts) {
+ state.compiledScripts = compiledScripts;
+ }
},
actions: {
async GET_SCRIPT_REVISIONS(context) {
@@ -320,6 +324,20 @@ export default {
Vue.$toast.error('Unable to edit stage direction style');
}
},
+ async GET_COMPILED_SCRIPTS(context) {
+ const response = await fetch(`${makeURL('/api/v1/show/script/compiled_scripts')}`, {
+ method: 'GET',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ if (response.ok) {
+ const respJson = await response.json();
+ context.commit('SET_COMPILED_SCRIPTS', respJson.scripts);
+ } else {
+ log.error('Unable to load compiled scripts');
+ }
+ }
},
getters: {
SCRIPT_REVISIONS(state) {
@@ -344,5 +362,8 @@ export default {
STAGE_DIRECTION_STYLES(state) {
return state.stageDirectionStyles;
},
+ COMPILED_SCRIPTS(state) {
+ return state.compiledScripts;
+ }
},
};
diff --git a/client/src/views/show/ShowConfigView.vue b/client/src/views/show/ShowConfigView.vue
index b80eaa40..756f4263 100644
--- a/client/src/views/show/ShowConfigView.vue
+++ b/client/src/views/show/ShowConfigView.vue
@@ -66,6 +66,15 @@
>
Scenes
+
+ Revisions
+
-
+
-
-
-
@@ -27,15 +24,13 @@
diff --git a/client/src/views/show/config/ConfigScriptRevisions.vue b/client/src/views/show/config/ConfigScriptRevisions.vue
new file mode 100644
index 00000000..aa1b3a6f
--- /dev/null
+++ b/client/src/views/show/config/ConfigScriptRevisions.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/src/vue_components/show/config/script/CompiledScripts.vue b/client/src/vue_components/show/config/script/CompiledScripts.vue
new file mode 100644
index 00000000..9d98557f
--- /dev/null
+++ b/client/src/vue_components/show/config/script/CompiledScripts.vue
@@ -0,0 +1,160 @@
+
+
+
+
+ {{ data.item.revision }}
+
+
+
+
+ Delete
+
+
+ Generate
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/src/vue_components/show/config/script/ScriptEditor.vue b/client/src/vue_components/show/config/script/ScriptEditor.vue
index ba75aec9..332fa62e 100644
--- a/client/src/vue_components/show/config/script/ScriptEditor.vue
+++ b/client/src/vue_components/show/config/script/ScriptEditor.vue
@@ -1,6 +1,6 @@
@@ -297,6 +297,21 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/src/views/user/Settings.vue b/client/src/views/user/Settings.vue
index 449b2e96..2284364e 100644
--- a/client/src/views/user/Settings.vue
+++ b/client/src/views/user/Settings.vue
@@ -27,6 +27,9 @@
>
+
+
+
@@ -40,11 +43,17 @@ import CueColourPreferences from '@/vue_components/user/settings/CueColourPrefer
import AboutUser from '@/vue_components/user/settings/AboutUser.vue';
import UserSettingsConfig from '@/vue_components/user/settings/Settings.vue';
import ApiToken from '@/vue_components/user/settings/ApiToken.vue';
+import ChangePassword from '@/vue_components/user/settings/ChangePassword.vue';
export default {
name: 'UserSettings',
components: {
- UserSettingsConfig, AboutUser, StageDirectionStyles, CueColourPreferences, ApiToken,
+ UserSettingsConfig,
+ AboutUser,
+ StageDirectionStyles,
+ CueColourPreferences,
+ ApiToken,
+ ChangePassword,
},
};
diff --git a/client/src/vue_components/config/ConfigUsers.vue b/client/src/vue_components/config/ConfigUsers.vue
index 2176e3e1..28d40dec 100644
--- a/client/src/vue_components/config/ConfigUsers.vue
+++ b/client/src/vue_components/config/ConfigUsers.vue
@@ -29,6 +29,14 @@
>
RBAC
+
+ Reset Password
+
+
+
+
@@ -70,10 +94,11 @@ import { mapActions, mapGetters } from 'vuex';
import CreateUser from '@/vue_components/user/CreateUser.vue';
import ConfigRbac from '@/vue_components/user/ConfigRbac.vue';
+import ResetPassword from '@/vue_components/user/ResetPassword.vue';
export default {
name: 'ConfigUsers',
- components: { CreateUser, ConfigRbac },
+ components: { CreateUser, ConfigRbac, ResetPassword },
data() {
return {
userFields: [
@@ -84,11 +109,12 @@ export default {
{ key: 'btn', label: '' },
],
editUser: null,
+ resetUser: null,
clientTimeout: null,
};
},
computed: {
- ...mapGetters(['SHOW_USERS', 'CURRENT_SHOW']),
+ ...mapGetters(['SHOW_USERS', 'CURRENT_SHOW', 'CURRENT_USER']),
},
async mounted() {
await this.getUsers();
@@ -103,6 +129,16 @@ export default {
setEditUser(userId) {
this.editUser = userId;
},
+ setResetUser(user) {
+ this.resetUser = user;
+ },
+ async handlePasswordReset() {
+ await this.getUsers();
+ },
+ closeResetPasswordModal() {
+ this.$bvModal.hide('reset-password');
+ this.resetUser = null;
+ },
async deleteUser(data) {
const msg = `Are you sure you want to delete ${data.item.username}?`;
const action = await this.$bvModal.msgBoxConfirm(msg, {});
diff --git a/client/src/vue_components/user/ResetPassword.vue b/client/src/vue_components/user/ResetPassword.vue
new file mode 100644
index 00000000..4b63e9e5
--- /dev/null
+++ b/client/src/vue_components/user/ResetPassword.vue
@@ -0,0 +1,164 @@
+
+
+
+
+ This will reset {{ username }} 's password to a randomly generated temporary password.
+ The user will be forced to change it on their next login.
+
+
+
+
+
+ Password Reset Successfully
+
+
+ The temporary password for {{ username }} is:
+
+
+
+
+
+
+
+
+
+
+ Copy
+
+
+
+
+
+
+ Make sure to share this password with the user securely. They will need to change it on their next login.
+
+
+
+
+
+ Cancel
+
+
+
+
+ Reset Password
+
+
+ Done
+
+
+
+
+
+
\ No newline at end of file
diff --git a/client/src/vue_components/user/settings/ChangePassword.vue b/client/src/vue_components/user/settings/ChangePassword.vue
new file mode 100644
index 00000000..cba8fc9b
--- /dev/null
+++ b/client/src/vue_components/user/settings/ChangePassword.vue
@@ -0,0 +1,168 @@
+
+
+
+
+
+
+ This is a required field.
+
+
+
+
+
+
+ This is a required field and must be at least 6 characters.
+
+
+
+
+
+
+ Passwords do not match.
+
+
+
+
+
+
+ Change Password
+
+
+
+
+
+
\ No newline at end of file
diff --git a/server/alembic_config/versions/01fb1d6c6b08_add_token_version_to_user.py b/server/alembic_config/versions/01fb1d6c6b08_add_token_version_to_user.py
new file mode 100644
index 00000000..9cf2c04d
--- /dev/null
+++ b/server/alembic_config/versions/01fb1d6c6b08_add_token_version_to_user.py
@@ -0,0 +1,40 @@
+"""add_token_version_to_user
+
+Revision ID: 01fb1d6c6b08
+Revises: da55004052c1
+Create Date: 2026-01-11 01:10:17.249232
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+
+# revision identifiers, used by Alembic.
+revision: str = "01fb1d6c6b08"
+down_revision: Union[str, None] = "da55004052c1"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Step 1: Add column as nullable
+ with op.batch_alter_table("user", schema=None) as batch_op:
+ batch_op.add_column(sa.Column("token_version", sa.Integer(), nullable=True))
+
+ # Step 2: Backfill with default value (0 for all existing users)
+ connection = op.get_bind()
+ connection.execute(
+ sa.text("UPDATE user SET token_version = 0 WHERE token_version IS NULL")
+ )
+
+ # Step 3: Make column non-nullable
+ with op.batch_alter_table("user", schema=None) as batch_op:
+ batch_op.alter_column("token_version", nullable=False)
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("user", schema=None) as batch_op:
+ batch_op.drop_column("token_version")
diff --git a/server/alembic_config/versions/da55004052c1_add_requires_password_change_to_user.py b/server/alembic_config/versions/da55004052c1_add_requires_password_change_to_user.py
new file mode 100644
index 00000000..c8392233
--- /dev/null
+++ b/server/alembic_config/versions/da55004052c1_add_requires_password_change_to_user.py
@@ -0,0 +1,44 @@
+"""add_requires_password_change_to_user
+
+Revision ID: da55004052c1
+Revises: b5a760d2ee49
+Create Date: 2026-01-10 18:21:49.635525
+
+"""
+
+from typing import Sequence, Union
+
+import sqlalchemy as sa
+from alembic import op
+
+
+# revision identifiers, used by Alembic.
+revision: str = "da55004052c1"
+down_revision: Union[str, None] = "b5a760d2ee49"
+branch_labels: Union[str, Sequence[str], None] = None
+depends_on: Union[str, Sequence[str], None] = None
+
+
+def upgrade() -> None:
+ # Step 1: Add column as nullable
+ with op.batch_alter_table("user", schema=None) as batch_op:
+ batch_op.add_column(
+ sa.Column("requires_password_change", sa.Boolean(), nullable=True)
+ )
+
+ # Step 2: Backfill with default value (False for all existing users)
+ connection = op.get_bind()
+ connection.execute(
+ sa.text(
+ "UPDATE user SET requires_password_change = 0 WHERE requires_password_change IS NULL"
+ )
+ )
+
+ # Step 3: Make column non-nullable
+ with op.batch_alter_table("user", schema=None) as batch_op:
+ batch_op.alter_column("requires_password_change", nullable=False)
+
+
+def downgrade() -> None:
+ with op.batch_alter_table("user", schema=None) as batch_op:
+ batch_op.drop_column("requires_password_change")
diff --git a/server/controllers/api/auth/__init__.py b/server/controllers/api/auth/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/controllers/api/auth/token.py b/server/controllers/api/auth/token.py
new file mode 100644
index 00000000..a12ae469
--- /dev/null
+++ b/server/controllers/api/auth/token.py
@@ -0,0 +1,78 @@
+import secrets
+
+from models.user import User
+from services.password_service import PasswordService
+from utils.web.base_controller import BaseAPIController
+from utils.web.route import ApiRoute, ApiVersion
+from utils.web.web_decorators import (
+ api_authenticated,
+)
+
+
+@ApiRoute("auth/api-token/generate", ApiVersion.V1)
+class ApiTokenGenerateController(BaseAPIController):
+ @api_authenticated
+ async def post(self):
+ """Generate a new API token for the authenticated user"""
+ with self.make_session() as session:
+ user = session.get(User, self.current_user["id"])
+ if not user:
+ self.set_status(404)
+ await self.finish({"message": "User not found"})
+ return
+
+ # Generate a secure random token (plain text to return to user)
+ new_token = secrets.token_urlsafe(32)
+
+ hashed_token = await PasswordService.hash_password(new_token)
+
+ user.api_token = hashed_token
+ session.commit()
+
+ self.set_status(200)
+ await self.finish(
+ {
+ "message": "API token generated successfully",
+ "api_token": new_token,
+ }
+ )
+
+
+@ApiRoute("auth/api-token/revoke", ApiVersion.V1)
+class ApiTokenRevokeController(BaseAPIController):
+ @api_authenticated
+ async def post(self):
+ """Revoke the API token for the authenticated user"""
+ with self.make_session() as session:
+ user = session.get(User, self.current_user["id"])
+ if not user:
+ self.set_status(404)
+ await self.finish({"message": "User not found"})
+ return
+
+ if not user.api_token:
+ self.set_status(400)
+ await self.finish({"message": "No API token to revoke"})
+ return
+
+ user.api_token = None
+ session.commit()
+
+ self.set_status(200)
+ await self.finish({"message": "API token revoked successfully"})
+
+
+@ApiRoute("auth/api-token", ApiVersion.V1)
+class ApiTokenController(BaseAPIController):
+ @api_authenticated
+ def get(self):
+ """Check if the authenticated user has an API token"""
+ with self.make_session() as session:
+ user = session.get(User, self.current_user["id"])
+ if not user:
+ self.set_status(404)
+ self.finish({"message": "User not found"})
+ return
+
+ self.set_status(200)
+ self.finish({"has_token": user.api_token is not None})
diff --git a/server/controllers/api/auth.py b/server/controllers/api/auth/user.py
similarity index 63%
rename from server/controllers/api/auth.py
rename to server/controllers/api/auth/user.py
index ae406693..4af56f50 100644
--- a/server/controllers/api/auth.py
+++ b/server/controllers/api/auth/user.py
@@ -1,18 +1,17 @@
-import secrets
from datetime import datetime, timezone
-import bcrypt
from sqlalchemy import select
-from tornado import escape, gen
-from tornado.ioloop import IOLoop
+from tornado import escape
from models.session import Session
from models.user import User
from registry.named_locks import NamedLockRegistry
from schemas.schemas import UserSchema
+from services.password_service import PasswordService
from utils.web.base_controller import BaseAPIController
from utils.web.route import ApiRoute, ApiVersion
from utils.web.web_decorators import (
+ allow_when_password_required,
api_authenticated,
no_live_session,
require_admin,
@@ -36,11 +35,12 @@ async def post(self):
self.set_status(400)
await self.finish({"message": "Password missing"})
return
- if len(password) < 6:
+
+ # Validate password strength
+ is_valid, error_msg = PasswordService.validate_password_strength(password)
+ if not is_valid:
self.set_status(400)
- await self.finish(
- {"message": "Password must be at least 6 characters long"}
- )
+ await self.finish({"message": error_msg})
return
is_admin = data.get("is_admin", False)
@@ -54,10 +54,7 @@ async def post(self):
await self.finish({"message": "Username already taken"})
return
- hashed_password = await IOLoop.current().run_in_executor(
- None, bcrypt.hashpw, escape.utf8(password), bcrypt.gensalt()
- )
- hashed_password = escape.to_unicode(hashed_password)
+ hashed_password = await PasswordService.hash_password(password)
session.add(
User(
@@ -104,30 +101,11 @@ async def post(self):
async with NamedLockRegistry.acquire(
f"UserLock::{user_to_delete.username}"
):
- # First, log out all sessions for this user
- await self.application.ws_send_to_user(
- user_to_delete.id, "NOOP", "USER_LOGOUT", {}
+ # Force logout all sessions using UserService
+ await self.application.user_service.force_logout_all_sessions(
+ session, user_to_delete
)
- # Then really make sure we have logged out the user for all sessions (basically,
- # wait for the websocket ops to finish)
- session_logout_attempts = 0
- user_sessions = session.scalars(
- select(Session).where(Session.user_id == user_to_delete.id)
- ).all()
- while user_sessions and session_logout_attempts < 5:
- for user_session in user_sessions:
- ws_session = self.application.get_ws(user_session.internal_id)
- await ws_session.write_message(
- {"OP": "NOOP", "DATA": "{}", "ACTION": "USER_LOGOUT"}
- )
- ws_session.current_user_id = None
- await gen.sleep(0.2)
- user_sessions = session.scalars(
- select(Session).where(Session.user_id == user_to_delete.id)
- ).all()
- session_logout_attempts += 1
-
# Delete all RBAC associations for this user
self.application.rbac.delete_actor(user_to_delete)
@@ -167,11 +145,8 @@ async def post(self):
await self.finish({"message": "Invalid username/password"})
return
- password_equal = await IOLoop.current().run_in_executor(
- None,
- bcrypt.checkpw,
- escape.utf8(password),
- escape.utf8(user.password),
+ password_equal = await PasswordService.verify_password(
+ password, user.password
)
if password_equal:
@@ -207,6 +182,7 @@ async def post(self):
@ApiRoute("auth/logout", ApiVersion.V1)
class LogoutHandler(BaseAPIController):
@api_authenticated
+ @allow_when_password_required
async def post(self):
data = escape.json_decode(self.request.body)
@@ -268,16 +244,36 @@ def get(self):
@ApiRoute("auth", ApiVersion.V1)
class AuthHandler(BaseAPIController):
+ @allow_when_password_required
def get(self):
self.set_status(200)
self.finish(self.current_user if self.current_user else {})
-@ApiRoute("auth/api-token/generate", ApiVersion.V1)
-class ApiTokenGenerateController(BaseAPIController):
+@ApiRoute("auth/change-password", ApiVersion.V1)
+class PasswordChangeController(BaseAPIController):
+ """Self-service password change endpoint"""
+
@api_authenticated
- async def post(self):
- """Generate a new API token for the authenticated user"""
+ @allow_when_password_required
+ async def patch(self):
+ """
+ Change authenticated user's password.
+
+ Request body: {old_password: str, new_password: str}
+ old_password is optional if requires_password_change=True
+
+ :return: 200 on success, 401 if old password wrong, 400 if new password invalid
+ """
+ data = escape.json_decode(self.request.body)
+ old_password = data.get("old_password", "")
+ new_password = data.get("new_password", "")
+
+ if not new_password:
+ self.set_status(400)
+ await self.finish({"message": "New password is required"})
+ return
+
with self.make_session() as session:
user = session.get(User, self.current_user["id"])
if not user:
@@ -285,62 +281,115 @@ async def post(self):
await self.finish({"message": "User not found"})
return
- # Generate a secure random token (plain text to return to user)
- new_token = secrets.token_urlsafe(32)
+ if not user.requires_password_change:
+ if not old_password:
+ self.set_status(400)
+ await self.finish({"message": "Old password is required"})
+ return
+
+ password_matches = await PasswordService.verify_password(
+ old_password, user.password
+ )
+ if not password_matches:
+ self.set_status(401)
+ await self.finish({"message": "Current password is incorrect"})
+ return
+
+ try:
+ # Increment token_version to invalidate old tokens, but don't
+ # broadcast logout since we're providing a new token for all sessions
+ await self.application.user_service.change_password(
+ session,
+ user,
+ new_password,
+ invalidate_tokens=True,
+ force_logout_sessions=False,
+ )
+ except ValueError as e:
+ self.set_status(400)
+ await self.finish({"message": str(e)})
+ return
- # Hash the token before storing (like passwords)
- hashed_token = await IOLoop.current().run_in_executor(
- None, bcrypt.hashpw, escape.utf8(new_token), bcrypt.gensalt()
+ # Generate new JWT token with updated token_version
+ new_token = self.application.jwt_service.create_access_token(
+ data={"user_id": user.id}
)
- hashed_token = escape.to_unicode(hashed_token)
- user.api_token = hashed_token
- session.commit()
+ # Broadcast new token to all user's sessions for seamless re-auth
+ await self.application.user_service.refresh_token_all_sessions(
+ user, new_token
+ )
self.set_status(200)
await self.finish(
{
- "message": "API token generated successfully",
- "api_token": new_token,
+ "message": "Password changed successfully",
+ "access_token": new_token,
+ "token_type": "bearer",
}
)
-@ApiRoute("auth/api-token/revoke", ApiVersion.V1)
-class ApiTokenRevokeController(BaseAPIController):
+@ApiRoute("auth/reset-password", ApiVersion.V1)
+class AdminPasswordResetController(BaseAPIController):
+ """Admin-initiated password reset endpoint"""
+
@api_authenticated
+ @require_admin
async def post(self):
- """Revoke the API token for the authenticated user"""
+ """
+ Reset a user's password to a temporary password (admin only).
+
+ Generates a temporary password and forces the user to change it on next login.
+ Admins cannot reset their own password via this endpoint.
+
+ Request body: {user_id: int}
+
+ :return: 200 with temporary password on success, 400 if validation fails
+ """
+ data = escape.json_decode(self.request.body)
+ user_id = data.get("user_id")
+
+ if not user_id:
+ self.set_status(400)
+ await self.finish({"message": "user_id is required"})
+ return
+
with self.make_session() as session:
- user = session.get(User, self.current_user["id"])
- if not user:
+ target_user = session.get(User, int(user_id))
+ if not target_user:
self.set_status(404)
await self.finish({"message": "User not found"})
return
- if not user.api_token:
+ if target_user.id == self.current_user["id"]:
self.set_status(400)
- await self.finish({"message": "No API token to revoke"})
+ await self.finish(
+ {"message": "Cannot reset your own password via admin endpoint"}
+ )
return
- user.api_token = None
- session.commit()
-
- self.set_status(200)
- await self.finish({"message": "API token revoked successfully"})
-
+ # Generate temporary password
+ temp_password = PasswordService.generate_temporary_password()
-@ApiRoute("auth/api-token", ApiVersion.V1)
-class ApiTokenController(BaseAPIController):
- @api_authenticated
- def get(self):
- """Check if the authenticated user has an API token"""
- with self.make_session() as session:
- user = session.get(User, self.current_user["id"])
- if not user:
- self.set_status(404)
- self.finish({"message": "User not found"})
+ # Change the password and force password change on next login
+ try:
+ await self.application.user_service.change_password(
+ session, target_user, temp_password, invalidate_tokens=True
+ )
+ except ValueError as e:
+ self.set_status(400)
+ await self.finish({"message": str(e)})
return
+ # Ensure requires_password_change is set
+ target_user.requires_password_change = True
+ session.commit()
+
self.set_status(200)
- self.finish({"has_token": user.api_token is not None})
+ await self.finish(
+ {
+ "message": "Password reset successfully",
+ "temporary_password": temp_password,
+ }
+ )
diff --git a/server/controllers/api/rbac.py b/server/controllers/api/rbac.py
index f7c4c2cf..a36df59b 100644
--- a/server/controllers/api/rbac.py
+++ b/server/controllers/api/rbac.py
@@ -8,11 +8,16 @@
from registry.schema import get_registry
from utils.web.base_controller import BaseAPIController
from utils.web.route import ApiRoute, ApiVersion
-from utils.web.web_decorators import api_authenticated, require_admin
+from utils.web.web_decorators import (
+ allow_when_password_required,
+ api_authenticated,
+ require_admin,
+)
@ApiRoute("rbac/roles", ApiVersion.V1)
class RBACRolesHandler(BaseAPIController):
+ @allow_when_password_required
async def get(self):
self.set_status(200)
await self.finish(
@@ -95,6 +100,7 @@ async def get(self):
@ApiRoute("rbac/user/roles", ApiVersion.V1)
class RBACUserRolesHandler(BaseAPIController):
@api_authenticated
+ @allow_when_password_required
async def get(self):
with self.make_session() as session:
res = defaultdict(list)
diff --git a/server/controllers/api/settings.py b/server/controllers/api/settings.py
index 24709d0c..4a0fefc3 100644
--- a/server/controllers/api/settings.py
+++ b/server/controllers/api/settings.py
@@ -4,11 +4,17 @@
from digi_server.settings import Settings
from utils.web.base_controller import BaseAPIController
from utils.web.route import ApiRoute, ApiVersion
-from utils.web.web_decorators import api_authenticated, no_live_session, require_admin
+from utils.web.web_decorators import (
+ allow_when_password_required,
+ api_authenticated,
+ no_live_session,
+ require_admin,
+)
@ApiRoute("settings", ApiVersion.V1)
class SettingsController(BaseAPIController):
+ @allow_when_password_required
async def get(self):
settings: Settings = self.application.digi_settings
settings_json = await settings.as_json()
diff --git a/server/controllers/api/user/settings.py b/server/controllers/api/user/settings.py
index 6ce35350..dbf3fdd5 100644
--- a/server/controllers/api/user/settings.py
+++ b/server/controllers/api/user/settings.py
@@ -5,12 +5,13 @@
from schemas.schemas import UserSettingsSchema
from utils.web.base_controller import BaseAPIController
from utils.web.route import ApiRoute, ApiVersion
-from utils.web.web_decorators import api_authenticated
+from utils.web.web_decorators import allow_when_password_required, api_authenticated
@ApiRoute("user/settings", ApiVersion.V1)
class UserSettingsController(BaseAPIController):
@api_authenticated
+ @allow_when_password_required
async def get(self):
schema = UserSettingsSchema()
with self.make_session() as session:
diff --git a/server/digi_server/app_server.py b/server/digi_server/app_server.py
index 208f00c3..eab787a2 100644
--- a/server/digi_server/app_server.py
+++ b/server/digi_server/app_server.py
@@ -27,6 +27,7 @@
from models.show import Show
from models.user import User
from rbac.rbac import RBACController
+from services.user_service import UserService
from utils.database import DigiSQLAlchemy
from utils.exceptions import DatabaseTypeException, DatabaseUpgradeRequired
from utils.module_discovery import get_resource_path, is_frozen
@@ -106,6 +107,9 @@ class AlembicVersion(self._db.Model):
# Configure the JWT service once we have set up the database
self.jwt_service = self._configure_jwt()
+ # Configure the User service
+ self.user_service = UserService(self)
+
# On startup, perform the following checks/operations with the database:
with self._db.sessionmaker() as session:
# 1. Check for presence of admin user, and update settings to match
diff --git a/server/models/user.py b/server/models/user.py
index 0e5ba9e6..5075859e 100644
--- a/server/models/user.py
+++ b/server/models/user.py
@@ -68,6 +68,8 @@ class User(db.Model):
last_login: Mapped[datetime.datetime | None] = mapped_column()
last_seen: Mapped[datetime.datetime | None] = mapped_column()
api_token: Mapped[str | None] = mapped_column(index=True)
+ requires_password_change: Mapped[bool] = mapped_column(default=False)
+ token_version: Mapped[int] = mapped_column(default=0)
sessions: Mapped[List["Session"]] = relationship(back_populates="user")
diff --git a/server/requirements.txt b/server/requirements.txt
index 4790a133..4e6355d9 100644
--- a/server/requirements.txt
+++ b/server/requirements.txt
@@ -9,4 +9,5 @@ anytree==2.13.0
alembic==1.17.2
marshmallow<5
pyjwt[crypto]==2.10.1
-setuptools==80.9.0
\ No newline at end of file
+setuptools==80.9.0
+xkcdpass==1.20.0
\ No newline at end of file
diff --git a/server/services/__init__.py b/server/services/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/services/password_service.py b/server/services/password_service.py
new file mode 100644
index 00000000..ded36598
--- /dev/null
+++ b/server/services/password_service.py
@@ -0,0 +1,86 @@
+"""Password service for hashing, verification, and generation"""
+
+import bcrypt
+import xkcdpass.xkcd_password as xp
+from tornado import escape
+from tornado.ioloop import IOLoop
+
+
+class PasswordService:
+ """Service for password-related operations"""
+
+ @staticmethod
+ async def hash_password(password: str) -> str:
+ """
+ Hash a password using bcrypt with auto-generated salt.
+
+ :param password: Plain text password to hash
+ :type password: str
+ :return: Bcrypt hash string suitable for database storage
+ :rtype: str
+ """
+ loop = IOLoop.current()
+ hashed = await loop.run_in_executor(
+ None, bcrypt.hashpw, escape.utf8(password), bcrypt.gensalt()
+ )
+ return escape.to_unicode(hashed)
+
+ @staticmethod
+ async def verify_password(password: str, hashed: str) -> bool:
+ """
+ Verify a password against a bcrypt hash.
+
+ :param password: Plain text password to verify
+ :type password: str
+ :param hashed: Bcrypt hash to verify against
+ :type hashed: str
+ :return: True if password matches hash, False otherwise
+ :rtype: bool
+ """
+ loop = IOLoop.current()
+ matches = await loop.run_in_executor(
+ None, bcrypt.checkpw, escape.utf8(password), escape.utf8(hashed)
+ )
+ return matches
+
+ @staticmethod
+ def validate_password_strength(password: str) -> tuple[bool, str]:
+ """
+ Validate password meets strength requirements.
+
+ Current requirements:
+ - Minimum 6 characters
+
+ :param password: Password to validate
+ :type password: str
+ :return: Tuple of (is_valid, error_message). is_valid is True if password
+ meets requirements. error_message is empty string if valid, error
+ description if invalid.
+ :rtype: tuple[bool, str]
+ """
+ if len(password) < 6:
+ return False, "Password must be at least 6 characters long"
+
+ return True, ""
+
+ @staticmethod
+ def generate_temporary_password(word_count: int = 3) -> str:
+ """
+ Generate a human-readable temporary password using EFF wordlist.
+
+ Uses the xkcdpass library with the EFF long wordlist (7776 words)
+ for cryptographically secure passphrase generation.
+
+ - 3 words = ~38.7 bits of entropy
+ - 4 words = ~51.6 bits of entropy
+
+ :param word_count: Number of words to use (default: 3)
+ :type word_count: int
+ :return: Dash-separated password like "correct-horse-battery"
+ :rtype: str
+ """
+ wordlist = xp.generate_wordlist(wordfile=xp.locate_wordfile())
+ password = xp.generate_xkcdpassword(
+ wordlist, numwords=word_count, delimiter="-"
+ )
+ return password
diff --git a/server/services/user_service.py b/server/services/user_service.py
new file mode 100644
index 00000000..f518640d
--- /dev/null
+++ b/server/services/user_service.py
@@ -0,0 +1,116 @@
+"""User service for user management operations"""
+
+from sqlalchemy import select
+from tornado import gen
+
+from models.session import Session
+from models.user import User
+from services.password_service import PasswordService
+
+
+class UserService:
+ """Service for user-level operations"""
+
+ def __init__(self, application):
+ """
+ Initialize UserService.
+
+ :param application: Tornado application instance
+ """
+ self.application = application
+
+ async def change_password(
+ self,
+ session,
+ user: User,
+ new_password: str,
+ invalidate_tokens: bool = True,
+ force_logout_sessions: bool = True,
+ ) -> None:
+ """
+ Change user's password and optionally invalidate all sessions.
+
+ :param session: SQLAlchemy session
+ :param user: User model instance
+ :type user: User
+ :param new_password: New password (plaintext)
+ :type new_password: str
+ :param invalidate_tokens: If True, increment token_version to invalidate JWTs
+ :type invalidate_tokens: bool
+ :param force_logout_sessions: If True, broadcast logout to all sessions
+ :type force_logout_sessions: bool
+ :raises ValueError: If password validation fails
+ """
+ is_valid, error_msg = PasswordService.validate_password_strength(new_password)
+ if not is_valid:
+ raise ValueError(error_msg)
+
+ hashed = await PasswordService.hash_password(new_password)
+
+ user.password = hashed
+ user.requires_password_change = False
+
+ if invalidate_tokens:
+ # Increment token version to invalidate all existing JWTs
+ user.token_version += 1
+
+ if force_logout_sessions:
+ # Force logout all WebSocket sessions
+ await self.force_logout_all_sessions(session, user)
+
+ session.commit()
+
+ async def refresh_token_all_sessions(self, user: User, new_token: str) -> None:
+ """
+ Broadcast new JWT token to all user's active sessions for seamless re-auth.
+
+ Sends TOKEN_REFRESH WebSocket message to all user sessions with the new token.
+ Each session can then update its stored auth token without interruption.
+
+ :param user: User model instance
+ :type user: User
+ :param new_token: New JWT access token
+ :type new_token: str
+ """
+ await self.application.ws_send_to_user(
+ user.id,
+ "NOOP",
+ "TOKEN_REFRESH",
+ {"access_token": new_token, "token_type": "bearer"},
+ )
+
+ async def force_logout_all_sessions(self, session, user: User) -> None:
+ """
+ Force logout user from all active sessions via WebSocket.
+
+ Process:
+ 1. Send USER_LOGOUT WebSocket message to all user sessions
+ 2. Wait for sessions to clear (with retry loop)
+ 3. Manually send logout to any remaining sessions
+ Best-effort - some sessions may not receive message if offline
+
+ :param session: SQLAlchemy session
+ :param user: User model instance
+ :type user: User
+ """
+ await self.application.ws_send_to_user(user.id, "NOOP", "USER_LOGOUT", {})
+
+ session_logout_attempts = 0
+ user_sessions = session.scalars(
+ select(Session).where(Session.user_id == user.id)
+ ).all()
+
+ while user_sessions and session_logout_attempts < 5:
+ for user_session in user_sessions:
+ ws_session = self.application.get_ws(user_session.internal_id)
+ if ws_session:
+ await ws_session.write_message(
+ {"OP": "NOOP", "DATA": "{}", "ACTION": "USER_LOGOUT"}
+ )
+ ws_session.current_user_id = None
+
+ await gen.sleep(0.2)
+ user_sessions = session.scalars(
+ select(Session).where(Session.user_id == user.id)
+ ).all()
+ session_logout_attempts += 1
diff --git a/server/test/controllers/api/test_auth.py b/server/test/controllers/api/test_auth.py
index 417174b9..825cba51 100644
--- a/server/test/controllers/api/test_auth.py
+++ b/server/test/controllers/api/test_auth.py
@@ -532,3 +532,540 @@ def test_get_users(self):
self.assertIn("admin", usernames)
self.assertIn("user1", usernames)
self.assertIn("user2", usernames)
+
+ def test_change_password_success(self):
+ """Test successful password change with valid old password"""
+ # Create and login a user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "testuser", "password": "oldpass123", "is_admin": False}
+ ),
+ )
+
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "testuser", "password": "oldpass123"}),
+ )
+ token = escape.json_decode(response.body)["access_token"]
+
+ # Change password
+ response = self.fetch(
+ "/api/v1/auth/change-password",
+ method="PATCH",
+ body=escape.json_encode(
+ {"old_password": "oldpass123", "new_password": "newpass456"}
+ ),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+
+ self.assertEqual(200, response.code)
+ response_body = escape.json_decode(response.body)
+ self.assertEqual("Password changed successfully", response_body["message"])
+ self.assertIn("access_token", response_body)
+ self.assertIn("token_type", response_body)
+
+ new_token = response_body["access_token"]
+
+ # Verify old token is invalidated (should get 401)
+ response = self.fetch(
+ "/api/v1/auth", method="GET", headers={"Authorization": f"Bearer {token}"}
+ )
+ self.assertEqual(401, response.code)
+
+ # Verify new token works (user stays logged in)
+ response = self.fetch(
+ "/api/v1/auth",
+ method="GET",
+ headers={"Authorization": f"Bearer {new_token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ # Verify can login with new password
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "testuser", "password": "newpass456"}),
+ )
+ self.assertEqual(200, response.code)
+
+ def test_change_password_incorrect_old_password(self):
+ """Test password change fails with incorrect old password"""
+ # Create and login a user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "testuser", "password": "oldpass123", "is_admin": False}
+ ),
+ )
+
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "testuser", "password": "oldpass123"}),
+ )
+ token = escape.json_decode(response.body)["access_token"]
+
+ # Try to change password with wrong old password
+ response = self.fetch(
+ "/api/v1/auth/change-password",
+ method="PATCH",
+ body=escape.json_encode(
+ {"old_password": "wrongpass", "new_password": "newpass456"}
+ ),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+
+ self.assertEqual(401, response.code)
+ response_body = escape.json_decode(response.body)
+ self.assertEqual("Current password is incorrect", response_body["message"])
+
+ def test_change_password_missing_new_password(self):
+ """Test password change fails when new password is missing"""
+ # Create and login a user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "testuser", "password": "oldpass123", "is_admin": False}
+ ),
+ )
+
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "testuser", "password": "oldpass123"}),
+ )
+ token = escape.json_decode(response.body)["access_token"]
+
+ # Try to change password without new password
+ response = self.fetch(
+ "/api/v1/auth/change-password",
+ method="PATCH",
+ body=escape.json_encode({"old_password": "oldpass123"}),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+
+ self.assertEqual(400, response.code)
+ response_body = escape.json_decode(response.body)
+ self.assertEqual("New password is required", response_body["message"])
+
+ def test_change_password_weak_password(self):
+ """Test password change fails with weak new password"""
+ # Create and login a user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "testuser", "password": "oldpass123", "is_admin": False}
+ ),
+ )
+
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "testuser", "password": "oldpass123"}),
+ )
+ token = escape.json_decode(response.body)["access_token"]
+
+ # Try to change to weak password (< 6 characters)
+ response = self.fetch(
+ "/api/v1/auth/change-password",
+ method="PATCH",
+ body=escape.json_encode(
+ {"old_password": "oldpass123", "new_password": "123"}
+ ),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+
+ self.assertEqual(400, response.code)
+ response_body = escape.json_decode(response.body)
+ self.assertIn("at least 6 characters", response_body["message"])
+
+ def test_change_password_requires_authentication(self):
+ """Test password change requires authentication"""
+ response = self.fetch(
+ "/api/v1/auth/change-password",
+ method="PATCH",
+ body=escape.json_encode(
+ {"old_password": "oldpass", "new_password": "newpass"}
+ ),
+ )
+
+ self.assertEqual(401, response.code)
+
+ def test_change_password_with_requires_password_change_flag(self):
+ """Test password change works without old password when requires_password_change=True"""
+ # Create user via API to ensure password is properly hashed
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {
+ "username": "forcedchange",
+ "password": "temppass123",
+ "is_admin": False,
+ }
+ ),
+ )
+
+ # Set requires_password_change flag
+ with self._app.get_db().sessionmaker() as session:
+ user = session.scalars(
+ select(User).where(User.username == "forcedchange")
+ ).first()
+ user.requires_password_change = True
+ session.commit()
+ user_id = user.id
+
+ # Login
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "forcedchange", "password": "temppass123"}
+ ),
+ )
+ token = escape.json_decode(response.body)["access_token"]
+
+ # Change password without providing old password
+ response = self.fetch(
+ "/api/v1/auth/change-password",
+ method="PATCH",
+ body=escape.json_encode({"new_password": "newpass123"}),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+
+ self.assertEqual(200, response.code)
+
+ # Verify requires_password_change is now False
+ with self._app.get_db().sessionmaker() as session:
+ user = session.get(User, user_id)
+ self.assertFalse(user.requires_password_change)
+
+ def test_password_enforcement_blocks_regular_endpoints(self):
+ """Test that requires_password_change blocks access to regular endpoints"""
+ # Create user with requires_password_change=True
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {
+ "username": "forcechange",
+ "password": "password123",
+ "is_admin": False,
+ }
+ ),
+ )
+
+ # Log in to get JWT
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "forcechange", "password": "password123"}
+ ),
+ )
+ self.assertEqual(200, response.code)
+ token = escape.json_decode(response.body)["access_token"]
+
+ # Set requires_password_change=True
+ with self._app.get_db().sessionmaker() as session:
+ user = session.scalars(
+ select(User).where(User.username == "forcechange")
+ ).first()
+ user.requires_password_change = True
+ session.commit()
+
+ # Try to access a regular endpoint (should be blocked with 403)
+ response = self.fetch(
+ "/api/v1/auth/api-token",
+ method="GET",
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ self.assertEqual(403, response.code)
+
+ def test_password_enforcement_allows_change_password_endpoint(self):
+ """Test that requires_password_change allows access to change-password"""
+ # Create user with requires_password_change=True
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {
+ "username": "forcechange2",
+ "password": "password123",
+ "is_admin": False,
+ }
+ ),
+ )
+
+ # Log in to get JWT
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "forcechange2", "password": "password123"}
+ ),
+ )
+ self.assertEqual(200, response.code)
+ token = escape.json_decode(response.body)["access_token"]
+
+ # Set requires_password_change=True
+ with self._app.get_db().sessionmaker() as session:
+ user = session.scalars(
+ select(User).where(User.username == "forcechange2")
+ ).first()
+ user.requires_password_change = True
+ session.commit()
+
+ # Try to change password (should be allowed)
+ response = self.fetch(
+ "/api/v1/auth/change-password",
+ method="PATCH",
+ body=escape.json_encode({"new_password": "newpassword123"}),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ def test_password_enforcement_allows_logout_endpoint(self):
+ """Test that requires_password_change allows access to logout"""
+ # Create user with requires_password_change=True
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {
+ "username": "forcechange3",
+ "password": "password123",
+ "is_admin": False,
+ }
+ ),
+ )
+
+ # Log in to get JWT
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "forcechange3", "password": "password123"}
+ ),
+ )
+ self.assertEqual(200, response.code)
+ token = escape.json_decode(response.body)["access_token"]
+
+ # Set requires_password_change=True
+ with self._app.get_db().sessionmaker() as session:
+ user = session.scalars(
+ select(User).where(User.username == "forcechange3")
+ ).first()
+ user.requires_password_change = True
+ session.commit()
+
+ # Try to logout (should be allowed)
+ response = self.fetch(
+ "/api/v1/auth/logout",
+ method="POST",
+ body=escape.json_encode({}),
+ headers={"Authorization": f"Bearer {token}"},
+ )
+ self.assertEqual(200, response.code)
+
+ def test_admin_reset_password_success(self):
+ """Test admin can successfully reset another user's password"""
+ # Create admin user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "admin", "password": "adminpass", "is_admin": True}
+ ),
+ )
+
+ # Login as admin
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "admin", "password": "adminpass"}),
+ )
+ admin_token = escape.json_decode(response.body)["access_token"]
+
+ # Create a regular user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "regularuser", "password": "userpass", "is_admin": False}
+ ),
+ )
+
+ # Get user ID
+ with self._app.get_db().sessionmaker() as session:
+ user = session.scalars(
+ select(User).where(User.username == "regularuser")
+ ).first()
+ user_id = user.id
+
+ # Admin resets user password
+ response = self.fetch(
+ "/api/v1/auth/reset-password",
+ method="POST",
+ body=escape.json_encode({"user_id": user_id}),
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+
+ self.assertEqual(200, response.code)
+ response_body = escape.json_decode(response.body)
+ self.assertEqual("Password reset successfully", response_body["message"])
+ self.assertIn("temporary_password", response_body)
+
+ temp_password = response_body["temporary_password"]
+
+ # Verify temporary password works
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "regularuser", "password": temp_password}
+ ),
+ )
+ self.assertEqual(200, response.code)
+
+ # Verify requires_password_change is True
+ with self._app.get_db().sessionmaker() as session:
+ user = session.get(User, user_id)
+ self.assertTrue(user.requires_password_change)
+
+ def test_admin_reset_password_cannot_reset_own(self):
+ """Test admin cannot reset their own password via admin endpoint"""
+ # Create admin user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "admin", "password": "adminpass", "is_admin": True}
+ ),
+ )
+
+ # Login as admin
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "admin", "password": "adminpass"}),
+ )
+ admin_token = escape.json_decode(response.body)["access_token"]
+
+ # Get admin user ID
+ with self._app.get_db().sessionmaker() as session:
+ admin = session.scalars(
+ select(User).where(User.username == "admin")
+ ).first()
+ admin_id = admin.id
+
+ # Try to reset own password
+ response = self.fetch(
+ "/api/v1/auth/reset-password",
+ method="POST",
+ body=escape.json_encode({"user_id": admin_id}),
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+
+ self.assertEqual(400, response.code)
+ response_body = escape.json_decode(response.body)
+ self.assertIn("Cannot reset your own password", response_body["message"])
+
+ def test_admin_reset_password_requires_admin(self):
+ """Test password reset requires admin privileges"""
+ # Create regular user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "regularuser", "password": "userpass", "is_admin": False}
+ ),
+ )
+
+ # Login as regular user
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "regularuser", "password": "userpass"}
+ ),
+ )
+ user_token = escape.json_decode(response.body)["access_token"]
+
+ # Try to reset password without admin privileges
+ response = self.fetch(
+ "/api/v1/auth/reset-password",
+ method="POST",
+ body=escape.json_encode({"user_id": 999}),
+ headers={"Authorization": f"Bearer {user_token}"},
+ )
+
+ self.assertEqual(401, response.code)
+
+ def test_admin_reset_password_user_not_found(self):
+ """Test password reset fails for non-existent user"""
+ # Create admin user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "admin", "password": "adminpass", "is_admin": True}
+ ),
+ )
+
+ # Login as admin
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "admin", "password": "adminpass"}),
+ )
+ admin_token = escape.json_decode(response.body)["access_token"]
+
+ # Try to reset password for non-existent user
+ response = self.fetch(
+ "/api/v1/auth/reset-password",
+ method="POST",
+ body=escape.json_encode({"user_id": 99999}),
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+
+ self.assertEqual(404, response.code)
+ response_body = escape.json_decode(response.body)
+ self.assertEqual("User not found", response_body["message"])
+
+ def test_admin_reset_password_missing_user_id(self):
+ """Test password reset fails when user_id is missing"""
+ # Create admin user
+ self.fetch(
+ "/api/v1/auth/create",
+ method="POST",
+ body=escape.json_encode(
+ {"username": "admin", "password": "adminpass", "is_admin": True}
+ ),
+ )
+
+ # Login as admin
+ response = self.fetch(
+ "/api/v1/auth/login",
+ method="POST",
+ body=escape.json_encode({"username": "admin", "password": "adminpass"}),
+ )
+ admin_token = escape.json_decode(response.body)["access_token"]
+
+ # Try to reset password without user_id
+ response = self.fetch(
+ "/api/v1/auth/reset-password",
+ method="POST",
+ body=escape.json_encode({}),
+ headers={"Authorization": f"Bearer {admin_token}"},
+ )
+
+ self.assertEqual(400, response.code)
+ response_body = escape.json_decode(response.body)
+ self.assertEqual("user_id is required", response_body["message"])
diff --git a/server/test/services/test_password_service.py b/server/test/services/test_password_service.py
new file mode 100644
index 00000000..5534a968
--- /dev/null
+++ b/server/test/services/test_password_service.py
@@ -0,0 +1,133 @@
+from tornado.testing import AsyncTestCase, gen_test
+
+from services.password_service import PasswordService
+
+
+class TestPasswordService(AsyncTestCase):
+ """Unit tests for PasswordService"""
+
+ @gen_test
+ async def test_hash_password_returns_valid_bcrypt_hash(self):
+ """Test that hash_password returns a valid bcrypt hash string"""
+ password = "test_password_123"
+ hashed = await PasswordService.hash_password(password)
+
+ # Bcrypt hashes are always 60 characters and start with $2b$ or $2a$
+ self.assertIsNotNone(hashed)
+ self.assertEqual(60, len(hashed))
+ self.assertTrue(hashed.startswith("$2b$") or hashed.startswith("$2a$"))
+
+ @gen_test
+ async def test_hash_password_produces_different_hashes(self):
+ """Test that hashing the same password twice produces different hashes"""
+ password = "test_password_123"
+ hash1 = await PasswordService.hash_password(password)
+ hash2 = await PasswordService.hash_password(password)
+
+ # Each hash should be unique due to random salt
+ self.assertNotEqual(hash1, hash2)
+
+ @gen_test
+ async def test_verify_password_with_correct_password(self):
+ """Test that verify_password returns True for correct password"""
+ password = "correct_password"
+ hashed = await PasswordService.hash_password(password)
+
+ result = await PasswordService.verify_password(password, hashed)
+ self.assertTrue(result)
+
+ @gen_test
+ async def test_verify_password_with_incorrect_password(self):
+ """Test that verify_password returns False for incorrect password"""
+ password = "correct_password"
+ wrong_password = "wrong_password"
+ hashed = await PasswordService.hash_password(password)
+
+ result = await PasswordService.verify_password(wrong_password, hashed)
+ self.assertFalse(result)
+
+ @gen_test
+ async def test_verify_password_case_sensitive(self):
+ """Test that password verification is case-sensitive"""
+ password = "TestPassword123"
+ hashed = await PasswordService.hash_password(password)
+
+ result = await PasswordService.verify_password("testpassword123", hashed)
+ self.assertFalse(result)
+
+ def test_validate_password_strength_minimum_length(self):
+ """Test that passwords must be at least 6 characters"""
+ # Valid passwords
+ is_valid, error_msg = PasswordService.validate_password_strength("123456")
+ self.assertTrue(is_valid)
+ self.assertEqual("", error_msg)
+
+ is_valid, error_msg = PasswordService.validate_password_strength(
+ "longer_password"
+ )
+ self.assertTrue(is_valid)
+ self.assertEqual("", error_msg)
+
+ def test_validate_password_strength_too_short(self):
+ """Test that passwords under 6 characters are rejected"""
+ is_valid, error_msg = PasswordService.validate_password_strength("12345")
+ self.assertFalse(is_valid)
+ self.assertEqual("Password must be at least 6 characters long", error_msg)
+
+ is_valid, error_msg = PasswordService.validate_password_strength("")
+ self.assertFalse(is_valid)
+ self.assertEqual("Password must be at least 6 characters long", error_msg)
+
+ def test_generate_temporary_password_default_word_count(self):
+ """Test that generate_temporary_password produces 3-word password by default"""
+ password = PasswordService.generate_temporary_password()
+
+ # Should be dash-separated words
+ words = password.split("-")
+ self.assertEqual(3, len(words))
+
+ # Each word should be non-empty and alphanumeric
+ for word in words:
+ self.assertGreater(len(word), 0)
+ self.assertTrue(word.isalpha())
+
+ def test_generate_temporary_password_custom_word_count(self):
+ """Test that generate_temporary_password respects word_count parameter"""
+ for word_count in [2, 3, 4, 5]:
+ password = PasswordService.generate_temporary_password(
+ word_count=word_count
+ )
+ words = password.split("-")
+ self.assertEqual(word_count, len(words))
+
+ def test_generate_temporary_password_uniqueness(self):
+ """Test that generate_temporary_password produces unique passwords"""
+ passwords = set()
+ for _ in range(10):
+ password = PasswordService.generate_temporary_password()
+ passwords.add(password)
+
+ # With 7776 words in EFF wordlist, collision probability is extremely low
+ # All 10 passwords should be unique
+ self.assertEqual(10, len(passwords))
+
+ def test_generate_temporary_password_format(self):
+ """Test that generated passwords only contain lowercase letters and dashes"""
+ password = PasswordService.generate_temporary_password()
+
+ # Should only contain lowercase letters and dashes
+ allowed_chars = set("abcdefghijklmnopqrstuvwxyz-")
+ self.assertTrue(all(c in allowed_chars for c in password))
+
+ # Should not start or end with dash
+ self.assertFalse(password.startswith("-"))
+ self.assertFalse(password.endswith("-"))
+
+ def test_generate_temporary_password_meets_minimum_length(self):
+ """Test that generated temporary passwords meet 6-character minimum"""
+ password = PasswordService.generate_temporary_password()
+ is_valid, _ = PasswordService.validate_password_strength(password)
+
+ # Generated passwords should always be valid
+ self.assertTrue(is_valid)
+ self.assertGreaterEqual(len(password), 6)
diff --git a/server/test/services/test_user_service.py b/server/test/services/test_user_service.py
new file mode 100644
index 00000000..57e71313
--- /dev/null
+++ b/server/test/services/test_user_service.py
@@ -0,0 +1,219 @@
+from unittest.mock import AsyncMock, MagicMock, patch
+
+from sqlalchemy import select
+from tornado.testing import gen_test
+
+from models.session import Session
+from models.user import User
+from test.conftest import DigiScriptTestCase
+
+
+class TestUserService(DigiScriptTestCase):
+ """Unit tests for UserService"""
+
+ def setUp(self):
+ super().setUp()
+ self.user_service = self._app.user_service
+
+ @gen_test
+ async def test_change_password_success(self):
+ """Test that change_password successfully updates user password"""
+ # Create a test user
+ with self._app.get_db().sessionmaker() as session:
+ user = User(username="testuser", password="old_hash")
+ session.add(user)
+ session.commit()
+ user_id = user.id
+
+ # Change password
+ with self._app.get_db().sessionmaker() as session:
+ user = session.get(User, user_id)
+ await self.user_service.change_password(
+ session, user, "new_password_123", invalidate_tokens=False
+ )
+
+ # Verify password was changed
+ session.refresh(user)
+ self.assertNotEqual("old_hash", user.password)
+ self.assertFalse(user.requires_password_change)
+
+ @gen_test
+ async def test_change_password_sets_requires_password_change_false(self):
+ """Test that change_password sets requires_password_change to False"""
+ # Create a user that requires password change
+ with self._app.get_db().sessionmaker() as session:
+ user = User(
+ username="testuser",
+ password="old_hash",
+ requires_password_change=True,
+ )
+ session.add(user)
+ session.commit()
+ user_id = user.id
+
+ # Change password
+ with self._app.get_db().sessionmaker() as session:
+ user = session.get(User, user_id)
+ await self.user_service.change_password(
+ session, user, "new_password_123", invalidate_tokens=False
+ )
+
+ # Verify requires_password_change is now False
+ session.refresh(user)
+ self.assertFalse(user.requires_password_change)
+
+ @gen_test
+ async def test_change_password_validates_password_strength(self):
+ """Test that change_password rejects weak passwords"""
+ # Create a test user
+ with self._app.get_db().sessionmaker() as session:
+ user = User(username="testuser", password="old_hash")
+ session.add(user)
+ session.commit()
+ user_id = user.id
+
+ # Try to change to weak password (< 6 characters)
+ with self._app.get_db().sessionmaker() as session:
+ user = session.get(User, user_id)
+ with self.assertRaises(ValueError) as context:
+ await self.user_service.change_password(
+ session, user, "short", invalidate_tokens=False
+ )
+
+ self.assertIn("at least 6 characters", str(context.exception))
+
+ @gen_test
+ async def test_change_password_with_invalidate_tokens(self):
+ """Test that change_password can invalidate tokens and force logout"""
+ # Create a test user
+ with self._app.get_db().sessionmaker() as session:
+ user = User(username="testuser", password="old_hash")
+ session.add(user)
+ session.commit()
+ user_id = user.id
+
+ # Mock the WebSocket send method
+ with patch.object(
+ self._app, "ws_send_to_user", new_callable=AsyncMock
+ ) as mock_ws_send:
+ with self._app.get_db().sessionmaker() as session:
+ user = session.get(User, user_id)
+ await self.user_service.change_password(
+ session, user, "new_password_123", invalidate_tokens=True
+ )
+
+ # Verify WebSocket message was sent to log out user
+ mock_ws_send.assert_called_once_with(user_id, "NOOP", "USER_LOGOUT", {})
+
+ @gen_test
+ async def test_force_logout_all_sessions_sends_websocket_message(self):
+ """Test that force_logout_all_sessions sends USER_LOGOUT message"""
+ # Create a test user
+ with self._app.get_db().sessionmaker() as session:
+ user = User(username="testuser", password="old_hash")
+ session.add(user)
+ session.commit()
+ user_id = user.id
+
+ # Mock the WebSocket send method
+ with patch.object(
+ self._app, "ws_send_to_user", new_callable=AsyncMock
+ ) as mock_ws_send:
+ with self._app.get_db().sessionmaker() as session:
+ user = session.get(User, user_id)
+ await self.user_service.force_logout_all_sessions(session, user)
+
+ # Verify WebSocket message was sent
+ mock_ws_send.assert_called_once_with(user_id, "NOOP", "USER_LOGOUT", {})
+
+ @gen_test
+ async def test_force_logout_all_sessions_with_active_sessions(self):
+ """Test that force_logout_all_sessions handles active WebSocket sessions"""
+ # Create a test user and session
+ with self._app.get_db().sessionmaker() as session:
+ user = User(username="testuser", password="old_hash")
+ session.add(user)
+ session.flush()
+
+ ws_session = Session(internal_id="test-session-id", user_id=user.id)
+ session.add(ws_session)
+ session.commit()
+ user_id = user.id
+
+ # Mock the WebSocket controller
+ mock_ws_controller = MagicMock()
+ mock_ws_controller.write_message = AsyncMock()
+ mock_ws_controller.current_user_id = user_id
+
+ with patch.object(
+ self._app, "ws_send_to_user", new_callable=AsyncMock
+ ) as mock_ws_send:
+ with patch.object(self._app, "get_ws", return_value=mock_ws_controller):
+ with self._app.get_db().sessionmaker() as session:
+ user = session.get(User, user_id)
+
+ # Clear the session so logout loop has nothing to clear
+ session.execute(select(Session).where(Session.user_id == user_id))
+
+ await self.user_service.force_logout_all_sessions(session, user)
+
+ # Verify WebSocket send was called
+ mock_ws_send.assert_called()
+
+ @gen_test
+ async def test_force_logout_all_sessions_without_websocket(self):
+ """Test that force_logout_all_sessions handles missing WebSocket gracefully"""
+ # Create a test user with a session but no active WebSocket
+ with self._app.get_db().sessionmaker() as session:
+ user = User(username="testuser", password="old_hash")
+ session.add(user)
+ session.flush()
+
+ ws_session = Session(internal_id="test-session-id", user_id=user.id)
+ session.add(ws_session)
+ session.commit()
+ user_id = user.id
+
+ # Mock get_ws to return None (no active WebSocket)
+ with patch.object(
+ self._app, "ws_send_to_user", new_callable=AsyncMock
+ ) as mock_ws_send:
+ with patch.object(self._app, "get_ws", return_value=None):
+ with self._app.get_db().sessionmaker() as session:
+ user = session.get(User, user_id)
+
+ # Should not raise an error even without active WebSocket
+ await self.user_service.force_logout_all_sessions(session, user)
+
+ # Verify WebSocket send was still called
+ mock_ws_send.assert_called_once()
+
+ @gen_test
+ async def test_change_password_hashes_password(self):
+ """Test that change_password properly hashes the new password"""
+ # Create a test user
+ with self._app.get_db().sessionmaker() as session:
+ user = User(username="testuser", password="old_hash")
+ session.add(user)
+ session.commit()
+ user_id = user.id
+
+ new_password = "new_password_123"
+
+ # Change password
+ with self._app.get_db().sessionmaker() as session:
+ user = session.get(User, user_id)
+ await self.user_service.change_password(
+ session, user, new_password, invalidate_tokens=False
+ )
+
+ session.refresh(user)
+
+ # Verify password is not stored in plain text
+ self.assertNotEqual(new_password, user.password)
+
+ # Verify it's a bcrypt hash (60 chars, starts with $2b$)
+ self.assertEqual(60, len(user.password))
+ self.assertTrue(
+ user.password.startswith("$2b$") or user.password.startswith("$2a$")
+ )
diff --git a/server/utils/web/base_controller.py b/server/utils/web/base_controller.py
index ec7bcf00..181505af 100644
--- a/server/utils/web/base_controller.py
+++ b/server/utils/web/base_controller.py
@@ -64,6 +64,10 @@ async def prepare(
payload = self.application.jwt_service.decode_access_token(token)
if payload and "user_id" in payload:
+ # Validate token version to check if token is still valid
+ if not self.application.jwt_service.is_token_version_valid(payload):
+ raise HTTPError(401, log_message="JWT token version invalid")
+
user = session.get(User, int(payload["user_id"]))
if user:
self.current_user = user_schema.dump(user)
@@ -95,6 +99,21 @@ async def prepare(
else:
raise HTTPError(401, log_message="Invalid API key")
+ # Enforce password change requirement
+ if self.current_user and self.current_user.get("requires_password_change"):
+ # Check if the current HTTP method is marked as exempt
+ method_name = self.request.method.lower()
+ handler_method = getattr(self, method_name, None)
+ is_exempt = getattr(
+ handler_method, "_allow_when_password_required", False
+ )
+
+ if not is_exempt:
+ raise HTTPError(
+ 403,
+ log_message="Password change required before accessing this resource",
+ )
+
current_show = await self.application.digi_settings.get("current_show")
if current_show:
show = session.get(Show, current_show)
diff --git a/server/utils/web/jwt_service.py b/server/utils/web/jwt_service.py
index 169e510f..db83b779 100644
--- a/server/utils/web/jwt_service.py
+++ b/server/utils/web/jwt_service.py
@@ -6,6 +6,7 @@
from sqlalchemy import select
from models.settings import SystemSettings
+from models.user import User
class JWTService:
@@ -74,7 +75,7 @@ def create_access_token(
self, data: Dict[str, Any], expires_delta: Optional[timedelta] = None
) -> str:
"""
- Create a new JWT access token
+ Create a new JWT access token with user token_version
"""
to_encode = data.copy()
@@ -83,6 +84,13 @@ def create_access_token(
else:
expire = datetime.now(tz=timezone.utc) + self._default_expiry
+ # Add token version if user_id is provided
+ if "user_id" in data and self.application:
+ with self.application.get_db().sessionmaker() as session:
+ user = session.get(User, data["user_id"])
+ if user:
+ to_encode["token_version"] = user.token_version
+
to_encode.update({"exp": expire, "iat": datetime.now(tz=timezone.utc)})
encoded_jwt = self._jwt.encode(
to_encode, self.get_secret(), algorithm=self._jwt_algorithm
@@ -105,6 +113,29 @@ def decode_access_token(self, token: str) -> Optional[Dict[str, Any]]:
except PyJWTError:
return None
+ def is_token_version_valid(self, payload: Dict[str, Any]) -> bool:
+ """
+ Validate that token version matches current user token version
+
+ :param payload: Decoded JWT payload
+ :type payload: Dict[str, Any]
+ :return: True if token version is valid, False otherwise
+ :rtype: bool
+ """
+ user_id = payload.get("user_id")
+ token_version = payload.get("token_version")
+
+ if user_id is None or token_version is None:
+ # Old tokens without version or missing user_id
+ return False
+
+ with self.application.get_db().sessionmaker() as session:
+ user = session.get(User, user_id)
+ if not user:
+ return False
+
+ return user.token_version == token_version
+
@staticmethod
def get_token_from_authorization_header(auth_header: str) -> Optional[str]:
"""
diff --git a/server/utils/web/web_decorators.py b/server/utils/web/web_decorators.py
index 67b6cd73..bdd9d877 100644
--- a/server/utils/web/web_decorators.py
+++ b/server/utils/web/web_decorators.py
@@ -53,3 +53,27 @@ def wrapper(self: BaseController, *args, **kwargs) -> Optional[Awaitable[None]]:
return method(self, *args, **kwargs)
return wrapper
+
+
+def allow_when_password_required(
+ method: Callable[..., Optional[Awaitable[None]]],
+) -> Callable[..., Optional[Awaitable[None]]]:
+ """
+ Decorator to mark a handler method as accessible even when user has requires_password_change=True.
+
+ Apply this to specific HTTP methods (get, post, patch, etc.) that should be accessible
+ during forced password change, such as change-password and logout endpoints.
+
+ Example:
+ @allow_when_password_required
+ async def patch(self):
+ # Password change logic
+ """
+
+ @functools.wraps(method)
+ def wrapper(self: BaseController, *args, **kwargs) -> Optional[Awaitable[None]]:
+ return method(self, *args, **kwargs)
+
+ # Mark the wrapper with an attribute so prepare() can detect it
+ wrapper._allow_when_password_required = True # type: ignore
+ return wrapper
From 5eb7dd97ebd755117d3075e68c89dcbbe53a4e20 Mon Sep 17 00:00:00 2001
From: Tim Bradgate
Date: Sun, 11 Jan 2026 20:21:17 +0000
Subject: [PATCH 08/16] Add Prettier config and update ESLint
---
.github/workflows/nodelint.yml | 2 +-
client/.prettierignore | 16 +
client/eslint.config.mjs | 35 +-
client/package-lock.json | 127 +++++-
client/package.json | 11 +-
client/prettier.config.mjs | 40 ++
client/src/App.vue | 138 +++----
client/src/assets/styles/dark.scss | 8 +-
client/src/assets/styles/light.scss | 2 +-
client/src/constants/textAlignment.js | 2 +-
client/src/js/customValidators.js | 4 +-
client/src/js/http-interceptor.js | 6 +-
client/src/js/micConflictUtils.js | 7 +-
client/src/js/micConflictUtils.test.js | 241 +++++++++---
client/src/js/scriptUtils.js | 11 +-
client/src/main.js | 71 ++--
client/src/mixins/cueDisplayMixin.js | 2 +-
client/src/mixins/passwordValidation.js | 2 +-
client/src/mixins/scriptDisplayMixin.js | 34 +-
client/src/mixins/scriptNavigationMixin.js | 30 +-
client/src/router/index.js | 28 +-
client/src/store/modules/help.js | 10 +-
client/src/store/modules/script.js | 48 +--
client/src/store/modules/scriptConfig.js | 14 +-
client/src/store/modules/show.js | 23 +-
client/src/store/modules/user/settings.js | 79 ++--
client/src/store/modules/user/user.js | 28 +-
client/src/store/modules/websocket.js | 34 +-
client/src/store/store.js | 79 ++--
client/src/views/AboutView.vue | 2 +-
client/src/views/HelpView.vue | 26 +-
client/src/views/config/ConfigView.vue | 15 +-
client/src/views/help/HelpDocView.vue | 40 +-
client/src/views/show/ShowConfigView.vue | 52 +--
client/src/views/show/ShowLiveView.vue | 363 +++++++++---------
client/src/views/show/config/ConfigActs.vue | 88 +----
client/src/views/show/config/ConfigCast.vue | 76 +---
.../show/config/ConfigCharacterGroups.vue | 91 ++---
.../views/show/config/ConfigCharacters.vue | 91 ++---
client/src/views/show/config/ConfigCues.vue | 83 +---
client/src/views/show/config/ConfigMics.vue | 51 +--
client/src/views/show/config/ConfigScenes.vue | 182 ++++-----
client/src/views/show/config/ConfigScript.vue | 16 +-
.../show/config/ConfigScriptRevisions.vue | 35 +-
.../src/views/show/config/ConfigSessions.vue | 19 +-
client/src/views/show/config/ConfigShow.vue | 71 +---
.../views/user/ForcePasswordChangeView.vue | 58 +--
client/src/views/user/LoginView.vue | 50 +--
client/src/views/user/Settings.vue | 23 +-
.../src/vue_components/MarkdownRenderer.vue | 60 +--
.../vue_components/config/ConfigSettings.vue | 39 +-
.../vue_components/config/ConfigSystem.vue | 130 ++-----
.../src/vue_components/config/ConfigUsers.vue | 48 +--
.../show/config/cast/CastLineStats.vue | 40 +-
.../config/characters/CharacterLineStats.vue | 36 +-
.../show/config/cues/CueCountStats.vue | 40 +-
.../show/config/cues/CueEditor.vue | 105 +++--
.../show/config/cues/JumpToCueModal.vue | 76 +---
.../show/config/cues/ScriptLineCueEditor.vue | 261 ++++++-------
.../show/config/mics/MicAllocations.vue | 126 +++---
.../show/config/mics/MicAutoPopulateModal.vue | 137 ++-----
.../show/config/mics/MicList.vue | 70 +---
.../show/config/mics/MicTimeline.vue | 84 ++--
.../show/config/mics/ResourceAvailability.vue | 76 +---
.../show/config/mics/SceneDensityHeatmap.vue | 68 +---
.../show/config/script/CompiledScripts.vue | 58 ++-
.../config/script/RevisionDetailModal.vue | 41 +-
.../show/config/script/RevisionGraph.vue | 108 ++----
.../show/config/script/ScriptEditor.vue | 251 ++++++------
.../show/config/script/ScriptLineEditor.vue | 91 ++---
.../show/config/script/ScriptLineViewer.vue | 106 +++--
.../show/config/script/ScriptRevisions.vue | 129 +++----
.../config/script/StageDirectionStyles.vue | 139 +++----
.../show/config/sessions/SessionList.vue | 35 +-
.../config/sessions/SessionTagDropdown.vue | 22 +-
.../show/config/sessions/SessionTagList.vue | 69 +---
.../show/live/ScriptLineViewer.vue | 263 ++++++-------
.../show/live/ScriptLineViewerCompact.vue | 215 +++++------
client/src/vue_components/user/ConfigRbac.vue | 21 +-
client/src/vue_components/user/CreateUser.vue | 18 +-
.../src/vue_components/user/RbacResource.vue | 21 +-
.../src/vue_components/user/ResetPassword.vue | 74 +---
.../user/settings/AboutUser.vue | 5 +-
.../vue_components/user/settings/ApiToken.vue | 73 ++--
.../user/settings/ChangePassword.vue | 20 +-
.../user/settings/CueColourPreferences.vue | 79 ++--
.../vue_components/user/settings/Settings.vue | 38 +-
.../user/settings/StageDirectionStyles.vue | 162 ++++----
88 files changed, 2506 insertions(+), 3492 deletions(-)
create mode 100644 client/.prettierignore
create mode 100644 client/prettier.config.mjs
diff --git a/.github/workflows/nodelint.yml b/.github/workflows/nodelint.yml
index 1e1e4716..17f4bd11 100644
--- a/.github/workflows/nodelint.yml
+++ b/.github/workflows/nodelint.yml
@@ -1,4 +1,4 @@
-name: ESLint
+name: Lint & Format (ESLint + Prettier)
on: [push]
diff --git a/client/.prettierignore b/client/.prettierignore
new file mode 100644
index 00000000..9a906847
--- /dev/null
+++ b/client/.prettierignore
@@ -0,0 +1,16 @@
+# Dependencies
+node_modules/
+
+# Build outputs
+dist/
+../server/static/
+
+# Test outputs
+coverage/
+junit/
+
+# Backups
+*.backup
+
+# Generated files
+src/docs/
\ No newline at end of file
diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs
index 023ed3fe..a62fecf1 100644
--- a/client/eslint.config.mjs
+++ b/client/eslint.config.mjs
@@ -2,6 +2,8 @@ import js from '@eslint/js';
import pluginVue from 'eslint-plugin-vue';
import globals from 'globals';
import babelParser from '@babel/eslint-parser';
+import prettierConfig from 'eslint-config-prettier';
+import prettierPlugin from 'eslint-plugin-prettier';
export default [
{
@@ -11,12 +13,16 @@ export default [
'../server/static/**',
'junit/**',
'*.backup',
+ 'src/docs/**',
],
},
js.configs.recommended,
...pluginVue.configs['flat/vue2-recommended'],
{
files: ['**/*.{js,vue}'],
+ plugins: {
+ prettier: prettierPlugin,
+ },
languageOptions: {
ecmaVersion: 2021,
sourceType: 'module',
@@ -37,27 +43,24 @@ export default [
},
},
rules: {
+ // Prettier integration - runs Prettier as an ESLint rule
+ 'prettier/prettier': 'error',
+
+ // Disable formatting rules that conflict with Prettier
+ ...prettierConfig.rules,
+
+ // Let Prettier handle line length (via printWidth config)
+ 'max-len': 'off',
+
+ // Custom linting rules (non-formatting)
'no-unused-vars': 'off',
'vue/no-unused-vars': 'off',
'no-plusplus': 'off',
- 'no-param-reassign': ['error', {
- props: true,
- ignorePropertyModificationsFor: [
- 'state',
- 'acc',
- 'e',
- ],
- }],
- 'max-len': [
+ 'no-param-reassign': [
'error',
- 150,
- 2,
{
- ignoreUrls: true,
- ignoreComments: false,
- ignoreRegExpLiterals: true,
- ignoreStrings: true,
- ignoreTemplateLiterals: true,
+ props: true,
+ ignorePropertyModificationsFor: ['state', 'acc', 'e'],
},
],
},
diff --git a/client/package-lock.json b/client/package-lock.json
index 2e1a31a2..24ab8ddb 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -42,9 +42,12 @@
"@vitest/ui": "^4.0.16",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.39.2",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^9.33.0",
"globals": "^15.14.0",
"jsdom": "^27.4.0",
+ "prettier": "^3.7.4",
"sass": "1.97.2",
"vite": "^7.3.1",
"vitest": "^4.0.16"
@@ -2933,6 +2936,19 @@
"node": ">=14"
}
},
+ "node_modules/@pkgr/core": {
+ "version": "0.2.9",
+ "resolved": "https://registry.npmjs.org/@pkgr/core/-/core-0.2.9.tgz",
+ "integrity": "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^12.20.0 || ^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/pkgr"
+ }
+ },
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -3360,6 +3376,23 @@
"prettier": "^1.18.2 || ^2.0.0"
}
},
+ "node_modules/@types/vuelidate/node_modules/prettier": {
+ "version": "2.8.8",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
+ "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "bin": {
+ "prettier": "bin-prettier.js"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ },
+ "funding": {
+ "url": "https://github.com/prettier/prettier?sponsor=1"
+ }
+ },
"node_modules/@types/vuelidate/node_modules/vue": {
"version": "2.7.16",
"resolved": "https://registry.npmjs.org/vue/-/vue-2.7.16.tgz",
@@ -4446,6 +4479,53 @@
}
}
},
+ "node_modules/eslint-config-prettier": {
+ "version": "10.1.8",
+ "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz",
+ "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "eslint-config-prettier": "bin/cli.js"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-config-prettier"
+ },
+ "peerDependencies": {
+ "eslint": ">=7.0.0"
+ }
+ },
+ "node_modules/eslint-plugin-prettier": {
+ "version": "5.5.4",
+ "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.4.tgz",
+ "integrity": "sha512-swNtI95SToIz05YINMA6Ox5R057IMAmWZ26GqPxusAp1TZzj+IdY9tXNWWD3vkF/wEqydCONcwjTFpxybBqZsg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "prettier-linter-helpers": "^1.0.0",
+ "synckit": "^0.11.7"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/eslint-plugin-prettier"
+ },
+ "peerDependencies": {
+ "@types/eslint": ">=8.0.0",
+ "eslint": ">=8.0.0",
+ "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0",
+ "prettier": ">=3.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/eslint": {
+ "optional": true
+ },
+ "eslint-config-prettier": {
+ "optional": true
+ }
+ }
+ },
"node_modules/eslint-plugin-vue": {
"version": "9.33.0",
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-9.33.0.tgz",
@@ -4704,6 +4784,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fast-diff": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
+ "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
+ "dev": true,
+ "license": "Apache-2.0"
+ },
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@@ -5824,22 +5911,34 @@
}
},
"node_modules/prettier": {
- "version": "2.8.8",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz",
- "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==",
+ "version": "3.7.4",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
+ "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true,
"license": "MIT",
- "optional": true,
"bin": {
- "prettier": "bin-prettier.js"
+ "prettier": "bin/prettier.cjs"
},
"engines": {
- "node": ">=10.13.0"
+ "node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
+ "node_modules/prettier-linter-helpers": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz",
+ "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fast-diff": "^1.1.2"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/proto-list": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
@@ -6305,6 +6404,22 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/synckit": {
+ "version": "0.11.11",
+ "resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.11.tgz",
+ "integrity": "sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@pkgr/core": "^0.2.9"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/synckit"
+ }
+ },
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
diff --git a/client/package.json b/client/package.json
index 2fd369db..bd9eb50e 100644
--- a/client/package.json
+++ b/client/package.json
@@ -6,9 +6,13 @@
"prebuild": "scripts/copy-docs.sh && node scripts/generate-doc-manifest.js",
"build": "vite build",
"build:analyze": "vite build --mode analyze",
- "lint": "eslint 'src/**/*.{js,vue}' --fix",
- "ci-lint": "eslint 'src/**/*.{js,vue}'",
+ "lint": "npm run format && npm run lint:eslint",
+ "lint:eslint": "eslint 'src/**/*.{js,vue}' --fix",
+ "ci-lint": "npm run format:check && npm run lint:eslint-check",
+ "lint:eslint-check": "eslint 'src/**/*.{js,vue}'",
"lint:filter": "./scripts/eslint-filter.sh",
+ "format": "prettier --write 'src/**/*.{js,vue,json,css,scss}'",
+ "format:check": "prettier --check 'src/**/*.{js,vue,json,css,scss}'",
"test": "vitest",
"test:ui": "vitest --ui",
"test:run": "vitest run",
@@ -54,9 +58,12 @@
"@vitest/ui": "^4.0.16",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.39.2",
+ "eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-vue": "^9.33.0",
"globals": "^15.14.0",
"jsdom": "^27.4.0",
+ "prettier": "^3.7.4",
"sass": "1.97.2",
"vite": "^7.3.1",
"vitest": "^4.0.16"
diff --git a/client/prettier.config.mjs b/client/prettier.config.mjs
new file mode 100644
index 00000000..67b1fa21
--- /dev/null
+++ b/client/prettier.config.mjs
@@ -0,0 +1,40 @@
+/**
+ * Prettier configuration for DigiScript frontend
+ * Based on Vue.js community recommendations
+ * @see https://prettier.io/docs/en/configuration.html
+ * @see https://vuejs.org/style-guide/
+ */
+export default {
+ // Standard Prettier defaults with Vue community preferences
+ printWidth: 100,
+ tabWidth: 2,
+ useTabs: false,
+ semi: true,
+ singleQuote: true,
+ quoteProps: 'as-needed',
+ trailingComma: 'es5',
+ bracketSpacing: true,
+ bracketSameLine: false,
+ arrowParens: 'always',
+ endOfLine: 'lf',
+
+ // Vue-specific options
+ vueIndentScriptAndStyle: false, // Don't indent
diff --git a/client/src/views/show/config/ConfigActs.vue b/client/src/views/show/config/ConfigActs.vue
index 912333a6..bbb8d744 100644
--- a/client/src/views/show/config/ConfigActs.vue
+++ b/client/src/views/show/config/ConfigActs.vue
@@ -1,8 +1,5 @@
-
+
Act List
@@ -15,39 +12,25 @@
show-empty
>
-
+
New Act
-
-
+
+
{{ ACT_BY_ID(data.item.next_act).name }}
-
- N/A
-
+ N/A
{{ ACT_BY_ID(data.item.previous_act).name }}
-
- N/A
-
+ N/A
@@ -88,15 +71,8 @@
@hidden="resetNewForm"
@ok="onSubmitNew"
>
-
-
+
+
-
+
This is a required field.
-
+
-
-
+
+
-
+
This is a required field.
-
+
-
+
This cannot form a circular dependency between acts.
@@ -267,12 +222,11 @@ export default {
if (this.CURRENT_SHOW.first_act_id != null && this.ACT_LIST.length > 0) {
let act = this.ACT_BY_ID(this.CURRENT_SHOW.first_act_id);
while (act != null) {
-
ret.push(this.ACT_BY_ID(act.id));
act = this.ACT_BY_ID(act.next_act);
}
}
- const actIds = ret.map((x) => (x.id));
+ const actIds = ret.map((x) => x.id);
this.ACT_LIST.forEach((act) => {
if (!actIds.includes(act.id)) {
ret.push(act);
@@ -283,7 +237,7 @@ export default {
previousActOptions() {
return [
{ value: null, text: 'None', disabled: false },
- ...this.ACT_LIST.filter((act) => (act.next_act == null), this).map((act) => ({
+ ...this.ACT_LIST.filter((act) => act.next_act == null, this).map((act) => ({
value: act.id,
text: act.name,
})),
@@ -291,9 +245,9 @@ export default {
},
editFormActOptions() {
const ret = [];
- ret.push(...this.previousActOptions.filter((act) => (act.value !== this.editFormState.id)));
+ ret.push(...this.previousActOptions.filter((act) => act.value !== this.editFormState.id));
if (this.editFormState.previous_act_id != null) {
- const act = this.ACT_LIST.find((a) => (a.id === this.editFormState.previous_act_id));
+ const act = this.ACT_LIST.find((a) => a.id === this.editFormState.previous_act_id);
ret.push({
value: this.editFormState.previous_act_id,
text: act.name,
@@ -416,6 +370,4 @@ export default {
};
-
+
diff --git a/client/src/views/show/config/ConfigCast.vue b/client/src/views/show/config/ConfigCast.vue
index 810375cc..5a274225 100644
--- a/client/src/views/show/config/ConfigCast.vue
+++ b/client/src/views/show/config/ConfigCast.vue
@@ -1,15 +1,9 @@
-
+
-
+
-
+
New Cast Member
@@ -71,15 +61,8 @@
@hidden="resetNewForm"
@ok="onSubmitNew"
>
-
-
+
+
-
+
This is a required field.
-
+
-
+
This is a required field.
@@ -122,15 +97,8 @@
@hidden="resetEditForm"
@ok="onSubmitEdit"
>
-
-
+
+
-
+
This is a required field.
-
+
-
+
This is a required field.
@@ -178,11 +138,7 @@ export default {
components: { CastLineStats },
data() {
return {
- castFields: [
- 'first_name',
- 'last_name',
- { key: 'btn', label: '' },
- ],
+ castFields: ['first_name', 'last_name', { key: 'btn', label: '' }],
newFormState: {
firstName: '',
lastName: '',
@@ -328,6 +284,4 @@ export default {
};
-
+
diff --git a/client/src/views/show/config/ConfigCharacterGroups.vue b/client/src/views/show/config/ConfigCharacterGroups.vue
index 3b6a50e6..91768465 100644
--- a/client/src/views/show/config/ConfigCharacterGroups.vue
+++ b/client/src/views/show/config/ConfigCharacterGroups.vue
@@ -1,8 +1,5 @@
-
+
Character Groups
@@ -15,19 +12,18 @@
show-empty
>
-
+
New Character Group
- {{ CHARACTER_LIST.filter((c) => (
- data.item.characters.includes(c.id))).map((c) => (c.name)).join(', ') }}
+ {{
+ CHARACTER_LIST.filter((c) => data.item.characters.includes(c.id))
+ .map((c) => c.name)
+ .join(', ')
+ }}
@@ -70,15 +66,8 @@
@hidden="resetNewForm"
@ok="onSubmitNew"
>
-
-
+
+
-
+
This is a required field.
@@ -104,11 +91,7 @@
:state="validateNewState('description')"
/>
-
+
-
-
+
+
-
+
This is a required field.
@@ -166,11 +140,7 @@
:state="validateEditState('description')"
/>
-
+
(character.id));
+ this.$v.newFormState.characters.$model = value.map((character) => character.id);
},
resetNewForm() {
this.tempCharacterList = [];
@@ -311,8 +276,11 @@ export default {
this.editFormState.description = characterGroup.item.description;
this.editFormState.characters = characterGroup.item.characters;
- this.tempEditCharacterList.push(...this.CHARACTER_LIST.filter((character) => (
- this.editFormState.characters.includes(character.id))));
+ this.tempEditCharacterList.push(
+ ...this.CHARACTER_LIST.filter((character) =>
+ this.editFormState.characters.includes(character.id)
+ )
+ );
this.$bvModal.show('edit-character-group');
}
@@ -333,7 +301,7 @@ export default {
});
},
editSelectChanged(value, id) {
- this.$v.editFormState.characters.$model = value.map((character) => (character.id));
+ this.$v.editFormState.characters.$model = value.map((character) => character.id);
},
async onSubmitEdit(event) {
this.$v.editFormState.$touch();
@@ -358,12 +326,15 @@ export default {
const { $dirty, $error } = this.$v.editFormState[name];
return $dirty ? !$error : null;
},
- ...mapActions(['GET_CHARACTER_LIST', 'GET_CHARACTER_GROUP_LIST', 'ADD_CHARACTER_GROUP',
- 'DELETE_CHARACTER_GROUP', 'UPDATE_CHARACTER_GROUP']),
+ ...mapActions([
+ 'GET_CHARACTER_LIST',
+ 'GET_CHARACTER_GROUP_LIST',
+ 'ADD_CHARACTER_GROUP',
+ 'DELETE_CHARACTER_GROUP',
+ 'UPDATE_CHARACTER_GROUP',
+ ]),
},
};
-
+
diff --git a/client/src/views/show/config/ConfigCharacters.vue b/client/src/views/show/config/ConfigCharacters.vue
index 2179ced1..345019ad 100644
--- a/client/src/views/show/config/ConfigCharacters.vue
+++ b/client/src/views/show/config/ConfigCharacters.vue
@@ -1,15 +1,9 @@
-
+
-
+
-
+
New Character
@@ -32,9 +22,7 @@
{{ data.item.cast_member.first_name }} {{ data.item.cast_member.last_name }}
-
- Set Cast Member
-
+ Set Cast Member
@@ -81,15 +69,8 @@
@hidden="resetNewForm"
@ok="onSubmitNew"
>
-
-
+
+
-
+
This is a required field.
@@ -115,11 +94,7 @@
:state="validateNewState('description')"
/>
-
+
-
-
+
+
-
+
This is a required field.
@@ -171,11 +137,7 @@
:state="validateEditState('description')"
/>
-
+
({ value: castMember.id, text: `${castMember.first_name} ${castMember.last_name}` })),
+ ...this.CAST_LIST.map((castMember) => ({
+ value: castMember.id,
+ text: `${castMember.first_name} ${castMember.last_name}`,
+ })),
];
},
},
@@ -358,11 +319,15 @@ export default {
}
}
},
- ...mapActions(['GET_CHARACTER_LIST', 'GET_CAST_LIST', 'ADD_CHARACTER', 'UPDATE_CHARACTER', 'DELETE_CHARACTER']),
+ ...mapActions([
+ 'GET_CHARACTER_LIST',
+ 'GET_CAST_LIST',
+ 'ADD_CHARACTER',
+ 'UPDATE_CHARACTER',
+ 'DELETE_CHARACTER',
+ ]),
},
};
-
+
diff --git a/client/src/views/show/config/ConfigCues.vue b/client/src/views/show/config/ConfigCues.vue
index 363e5e49..5ab4fe39 100644
--- a/client/src/views/show/config/ConfigCues.vue
+++ b/client/src/views/show/config/ConfigCues.vue
@@ -1,15 +1,9 @@
-
+
-
+
-
+
New Cue Type
-
+
@@ -79,15 +69,8 @@
@hidden="resetNewCueTypeForm"
@ok="onSubmitNewCueType"
>
-
-
+
+
-
+
This is a required field and must be 5 characters or less.
@@ -113,17 +94,11 @@
:state="validateNewCueTypeState('description')"
aria-describedby="description-feedback"
/>
-
+
This is a required field and must be 100 characters or less.
-
+
-
+
This is a required field.
@@ -149,15 +122,8 @@
@hidden="resetEditCueTypeForm"
@ok="onSubmitEditCueType"
>
-
-
+
+
-
+
This is a required field and must be 5 characters or less.
@@ -183,17 +147,11 @@
:state="validateEditCueTypeState('description')"
aria-describedby="description-feedback"
/>
-
+
This is a required field and must be 100 characters or less.
-
+
-
+
This is a required field.
@@ -226,12 +182,7 @@ export default {
components: { CueCountStats, CueEditor },
data() {
return {
- cueTypeFields: [
- 'prefix',
- 'description',
- 'colour',
- { key: 'btn', label: '' },
- ],
+ cueTypeFields: ['prefix', 'description', 'colour', { key: 'btn', label: '' }],
rowsPerPage: 15,
currentPage: 1,
newCueTypeForm: {
diff --git a/client/src/views/show/config/ConfigMics.vue b/client/src/views/show/config/ConfigMics.vue
index 92c79e9b..671bc213 100644
--- a/client/src/views/show/config/ConfigMics.vue
+++ b/client/src/views/show/config/ConfigMics.vue
@@ -1,47 +1,28 @@
-
+
-
+
-
+
-
+
-
+
-
+
-
-
+
+
@@ -59,7 +40,11 @@ import ResourceAvailability from '@/vue_components/show/config/mics/ResourceAvai
export default {
name: 'ConfigMics',
components: {
- MicAllocations, MicList, MicTimeline, SceneDensityHeatmap, ResourceAvailability,
+ MicAllocations,
+ MicList,
+ MicTimeline,
+ SceneDensityHeatmap,
+ ResourceAvailability,
},
data() {
return {
@@ -76,8 +61,14 @@ export default {
this.loaded = true;
},
methods: {
- ...mapActions(['GET_SCENE_LIST', 'GET_ACT_LIST', 'GET_CHARACTER_LIST',
- 'GET_CAST_LIST', 'GET_MICROPHONE_LIST', 'GET_MIC_ALLOCATIONS']),
+ ...mapActions([
+ 'GET_SCENE_LIST',
+ 'GET_ACT_LIST',
+ 'GET_CHARACTER_LIST',
+ 'GET_CAST_LIST',
+ 'GET_MICROPHONE_LIST',
+ 'GET_MIC_ALLOCATIONS',
+ ]),
},
};
diff --git a/client/src/views/show/config/ConfigScenes.vue b/client/src/views/show/config/ConfigScenes.vue
index a616072b..a2bb3258 100644
--- a/client/src/views/show/config/ConfigScenes.vue
+++ b/client/src/views/show/config/ConfigScenes.vue
@@ -1,8 +1,5 @@
-
+
Scene List
@@ -15,11 +12,7 @@
show-empty
>
-
+
New Scene
@@ -30,17 +23,13 @@
{{ SCENE_BY_ID(data.item.next_scene).name }}
-
- N/A
-
+ N/A
{{ SCENE_BY_ID(data.item.previous_scene).name }}
-
- N/A
-
+ N/A
@@ -72,19 +61,12 @@
Act First Scenes
-
+
{{ SCENE_BY_ID(data.item.first_scene).name }}
-
- N/A
-
+ N/A
@@ -110,15 +92,8 @@
@hidden="resetNewForm"
@ok="onSubmitNew"
>
-
-
+
+
-
+
This is a required field.
-
+
-
+
This is a required field.
@@ -173,15 +140,8 @@
@hidden="resetEditForm"
@ok="onSubmitEdit"
>
-
-
+
+
-
+
This is a required field.
-
+
-
+
This is a required field.
@@ -226,9 +178,7 @@
:state="validateEditState('previous_scene_id')"
aria-describedby="previous-scene-feedback"
/>
-
+
This cannot form a circular dependency between scenes.
@@ -243,10 +193,7 @@
@hidden="resetFirstSceneForm"
@ok="onSubmitFirstScene"
>
-
+
(value != null && value > 0),
+ notNullAndGreaterThanZero: (value) => value != null && value > 0,
},
previous_scene_id: {
integer,
@@ -326,7 +273,7 @@ export default {
required,
},
act_id: {
- notNullAndGreaterThanZero: (value) => (value != null && value > 0),
+ notNullAndGreaterThanZero: (value) => value != null && value > 0,
},
previous_scene_id: {
integer,
@@ -345,14 +292,20 @@ export default {
},
},
computed: {
- ...mapGetters(['SCENE_LIST', 'ACT_LIST', 'CURRENT_SHOW', 'SCENE_BY_ID', 'ACT_BY_ID', 'IS_SHOW_EDITOR']),
+ ...mapGetters([
+ 'SCENE_LIST',
+ 'ACT_LIST',
+ 'CURRENT_SHOW',
+ 'SCENE_BY_ID',
+ 'ACT_BY_ID',
+ 'IS_SHOW_EDITOR',
+ ]),
sceneTableItems() {
// Get ordering of Acts
const acts = [];
if (this.CURRENT_SHOW.first_act_id != null && this.ACT_LIST.length > 0) {
let act = this.ACT_BY_ID(this.CURRENT_SHOW.first_act_id);
while (act != null) {
-
acts.push(act.id);
act = this.ACT_BY_ID(act.next_act);
}
@@ -364,17 +317,16 @@ export default {
});
const ret = [];
acts.forEach((actId) => {
- const act = this.ACT_LIST.find((a) => (a.id === actId));
+ const act = this.ACT_LIST.find((a) => a.id === actId);
if (act.first_scene != null) {
let scene = this.SCENE_BY_ID(act.first_scene);
while (scene != null) {
-
ret.push(this.SCENE_BY_ID(scene.id));
scene = this.SCENE_BY_ID(scene.next_scene);
}
}
- const sceneIds = ret.map((s) => (s.id));
- this.SCENE_LIST.filter((s) => (s.act === actId)).forEach((scene) => {
+ const sceneIds = ret.map((s) => s.id);
+ this.SCENE_LIST.filter((s) => s.act === actId).forEach((scene) => {
if (!sceneIds.includes(scene.id)) {
ret.push(scene);
}
@@ -395,14 +347,19 @@ export default {
this.ACT_LIST.forEach((act) => {
ret[act.id] = [
{
- value: null, text: 'None', disabled: false,
+ value: null,
+ text: 'None',
+ disabled: false,
},
- ...this.SCENE_LIST.filter((scene) => (
- scene.act === act.id && scene.next_scene == null
- && this.ACT_BY_ID(scene.act) != null), this).map((scene) => ({
+ ...this.SCENE_LIST.filter(
+ (scene) =>
+ scene.act === act.id && scene.next_scene == null && this.ACT_BY_ID(scene.act) != null,
+ this
+ ).map((scene) => ({
value: scene.id,
text: `${this.ACT_BY_ID(scene.act).name}: ${scene.name}`,
- }))];
+ })),
+ ];
}, this);
return ret;
@@ -410,16 +367,23 @@ export default {
firstSceneOptions() {
const ret = {};
this.ACT_LIST.forEach((act) => {
- ret[act.id] = [{
- value: null,
- text: 'None',
- disabled: false,
- }, ...act.scene_list.filter((scene) => (
- this.SCENE_BY_ID(scene) != null
- && this.SCENE_BY_ID(scene).previous_scene == null), this).map((scene) => ({
- value: scene,
- text: `${act.name}: ${this.SCENE_BY_ID(scene).name}`,
- }))];
+ ret[act.id] = [
+ {
+ value: null,
+ text: 'None',
+ disabled: false,
+ },
+ ...act.scene_list
+ .filter(
+ (scene) =>
+ this.SCENE_BY_ID(scene) != null && this.SCENE_BY_ID(scene).previous_scene == null,
+ this
+ )
+ .map((scene) => ({
+ value: scene,
+ text: `${act.name}: ${this.SCENE_BY_ID(scene).name}`,
+ })),
+ ];
}, this);
return ret;
},
@@ -427,17 +391,17 @@ export default {
if (this.firstSceneFormState.act_id == null) {
return '';
}
- return `${this.ACT_LIST.find((act) => (act.id === this.firstSceneFormState.act_id)).name} First Scene`;
+ return `${this.ACT_LIST.find((act) => act.id === this.firstSceneFormState.act_id).name} First Scene`;
},
editFormPrevScenes() {
const ret = [];
- ret.push(...this.previousSceneOptions[this.editFormState.act_id].filter(
- (scene) => (scene.value !== this.editFormState.scene_id),
- ));
+ ret.push(
+ ...this.previousSceneOptions[this.editFormState.act_id].filter(
+ (scene) => scene.value !== this.editFormState.scene_id
+ )
+ );
if (this.editFormState.previous_scene_id != null) {
- const scene = this.SCENE_LIST.find(
- (s) => (s.id === this.editFormState.previous_scene_id),
- );
+ const scene = this.SCENE_LIST.find((s) => s.id === this.editFormState.previous_scene_id);
ret.push({
value: this.editFormState.previous_scene_id,
text: `${this.ACT_BY_ID(scene.act).name}: ${scene.name}`,
@@ -597,7 +561,7 @@ export default {
}
},
editActChanged(newActID) {
- const editScene = this.SCENE_LIST.find((s) => (s.id === this.editSceneID));
+ const editScene = this.SCENE_LIST.find((s) => s.id === this.editSceneID);
if (newActID !== editScene.act) {
this.editFormState.previous_scene_id = null;
} else if (editScene.previous_scene != null) {
@@ -606,12 +570,16 @@ export default {
this.editFormState.previous_scene_id = null;
}
},
- ...mapActions(['GET_SCENE_LIST', 'GET_ACT_LIST', 'ADD_SCENE', 'DELETE_SCENE',
- 'SET_ACT_FIRST_SCENE', 'UPDATE_SCENE']),
+ ...mapActions([
+ 'GET_SCENE_LIST',
+ 'GET_ACT_LIST',
+ 'ADD_SCENE',
+ 'DELETE_SCENE',
+ 'SET_ACT_FIRST_SCENE',
+ 'UPDATE_SCENE',
+ ]),
},
};
-
+
diff --git a/client/src/views/show/config/ConfigScript.vue b/client/src/views/show/config/ConfigScript.vue
index baf28d1d..3fdea0ad 100644
--- a/client/src/views/show/config/ConfigScript.vue
+++ b/client/src/views/show/config/ConfigScript.vue
@@ -1,15 +1,9 @@
-
+
-
+
@@ -30,10 +24,8 @@ export default {
components: {
ScriptConfig,
StageDirectionConfigs: StageDirectionStyles,
- }
+ },
};
-
+
diff --git a/client/src/views/show/config/ConfigScriptRevisions.vue b/client/src/views/show/config/ConfigScriptRevisions.vue
index aa1b3a6f..cbb992df 100644
--- a/client/src/views/show/config/ConfigScriptRevisions.vue
+++ b/client/src/views/show/config/ConfigScriptRevisions.vue
@@ -1,15 +1,9 @@
-
+
-
+
@@ -20,10 +14,7 @@
-
+
@@ -32,14 +23,14 @@
-
\ No newline at end of file
+
diff --git a/client/src/views/show/config/ConfigSessions.vue b/client/src/views/show/config/ConfigSessions.vue
index 15d3f831..3e517768 100644
--- a/client/src/views/show/config/ConfigSessions.vue
+++ b/client/src/views/show/config/ConfigSessions.vue
@@ -1,15 +1,9 @@
-
+
-
+
@@ -18,13 +12,8 @@
-
-
+
+
diff --git a/client/src/views/show/config/ConfigShow.vue b/client/src/views/show/config/ConfigShow.vue
index 67e9f53c..4aa0dff0 100644
--- a/client/src/views/show/config/ConfigShow.vue
+++ b/client/src/views/show/config/ConfigShow.vue
@@ -1,14 +1,7 @@
-
+
-
+
-
+
{{ key }}
{{ tableData[key] != null ? tableData[key] : 'N/A' }}
@@ -41,15 +31,8 @@
@hidden="resetEditForm"
@ok="onSubmitEdit"
>
-
-
+
+
-
+
This is a required field and must be less than 100 characters.
-
+
-
+
This is a required field and must be before or the same as the end date.
-
+
-
+
This is a required field and must be after or the same as the start date.
-
+
(value == null && vm.end_date != null ? false
- : new Date(value) <= new Date(vm.end_date)),
+ beforeEnd: (value, vm) =>
+ value == null && vm.end_date != null ? false : new Date(value) <= new Date(vm.end_date),
},
end_date: {
required,
- afterStart: (value, vm) => (value == null && vm.start_date != null ? false
- : new Date(value) >= new Date(vm.start_date)),
+ afterStart: (value, vm) =>
+ value == null && vm.start_date != null
+ ? false
+ : new Date(value) >= new Date(vm.start_date),
},
first_act_id: {},
},
@@ -234,7 +201,7 @@ export default {
diff --git a/client/src/views/user/ForcePasswordChangeView.vue b/client/src/views/user/ForcePasswordChangeView.vue
index 374b0a32..2223d0bb 100644
--- a/client/src/views/user/ForcePasswordChangeView.vue
+++ b/client/src/views/user/ForcePasswordChangeView.vue
@@ -1,35 +1,18 @@
-
+
-
+
-
+
Password Change Required
-
-
+
+
Your password must be changed before you can continue.
@@ -74,33 +57,14 @@
-
-
+
+
Logout
-
-
-
+
+
+
Change Password
@@ -198,4 +162,4 @@ export default {
.force-password-change-container {
margin-top: 2rem;
}
-
\ No newline at end of file
+
diff --git a/client/src/views/user/LoginView.vue b/client/src/views/user/LoginView.vue
index 57932909..84b7dbbe 100644
--- a/client/src/views/user/LoginView.vue
+++ b/client/src/views/user/LoginView.vue
@@ -1,27 +1,14 @@
-
+
-
+
Login to DigiScript
-
+
-
+
-
+
This is a required field.
-
+
-
+
This is a required field.
-
- Login
-
+ Login
-
- Login unsuccessful.
-
+ Login unsuccessful.
@@ -129,6 +101,4 @@ export default {
};
-
+
diff --git a/client/src/views/user/Settings.vue b/client/src/views/user/Settings.vue
index 2284364e..e80c2a4b 100644
--- a/client/src/views/user/Settings.vue
+++ b/client/src/views/user/Settings.vue
@@ -1,30 +1,17 @@
-
+
User Settings
-
-
+
+
-
+
-
+
diff --git a/client/src/vue_components/MarkdownRenderer.vue b/client/src/vue_components/MarkdownRenderer.vue
index a6ed7ec0..5f23502b 100644
--- a/client/src/vue_components/MarkdownRenderer.vue
+++ b/client/src/vue_components/MarkdownRenderer.vue
@@ -1,9 +1,6 @@
-
+
@@ -29,12 +26,31 @@ export default {
transformedHtml = this.transformMarkdownLinks(transformedHtml);
return DOMPurify.sanitize(transformedHtml, {
ALLOWED_TAGS: [
- 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
- 'p', 'a', 'ul', 'ol', 'li',
- 'img', 'code', 'pre',
- 'strong', 'em', 'blockquote',
- 'table', 'thead', 'tbody', 'tr', 'th', 'td',
- 'br', 'hr',
+ 'h1',
+ 'h2',
+ 'h3',
+ 'h4',
+ 'h5',
+ 'h6',
+ 'p',
+ 'a',
+ 'ul',
+ 'ol',
+ 'li',
+ 'img',
+ 'code',
+ 'pre',
+ 'strong',
+ 'em',
+ 'blockquote',
+ 'table',
+ 'thead',
+ 'tbody',
+ 'tr',
+ 'th',
+ 'td',
+ 'br',
+ 'hr',
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class'],
});
@@ -48,35 +64,27 @@ export default {
transformImagePaths(html) {
// Transform: ../images/topic/file.png → /docs/images/topic/file.png
// Also handles: ../../images/topic/file.png for nested docs
- return html.replace(
- /src="\.\.\/\.\.\/images\//g,
- 'src="/docs/images/',
- ).replace(
- /src="\.\.\/images\//g,
- 'src="/docs/images/',
- );
+ return html
+ .replace(/src="\.\.\/\.\.\/images\//g, 'src="/docs/images/')
+ .replace(/src="\.\.\/images\//g, 'src="/docs/images/');
},
transformMarkdownLinks(html) {
// Transform internal .md links to /help routes
// Examples:
// href="./pages/getting_started.md" → href="/help/getting-started"
// href="../pages/show_config.md" → href="/help/show-config"
- return html.replace(
- /href="(\.\.\/)*(pages\/[^"]+)\.md"/g,
- (match, dots, path) => {
+ return html
+ .replace(/href="(\.\.\/)*(pages\/[^"]+)\.md"/g, (match, dots, path) => {
// Remove 'pages/' prefix and convert underscores to dashes
const withoutPages = path.replace(/^pages\//, '');
const slug = withoutPages.replace(/_/g, '-');
return `href="/help/${slug}"`;
- },
- ).replace(
- /href="\.\/([^"]+)\.md"/g,
- (match, path) => {
+ })
+ .replace(/href="\.\/([^"]+)\.md"/g, (match, path) => {
// Handle same-directory links
const slug = path.replace(/_/g, '-');
return `href="/help/${slug}"`;
- },
- );
+ });
},
},
};
diff --git a/client/src/vue_components/config/ConfigSettings.vue b/client/src/vue_components/config/ConfigSettings.vue
index d40da65a..2fec9230 100644
--- a/client/src/vue_components/config/ConfigSettings.vue
+++ b/client/src/vue_components/config/ConfigSettings.vue
@@ -1,8 +1,5 @@
-
+
@@ -29,10 +26,7 @@
-
+
{{ setting.help_text }}
@@ -57,34 +51,15 @@
:switch="true"
/>
-
-
- Reset
-
-
- Submit
-
+
+ Reset
+ Submit
-
-
+
+
diff --git a/client/src/vue_components/config/ConfigSystem.vue b/client/src/vue_components/config/ConfigSystem.vue
index 1dc32a2f..794ff48f 100644
--- a/client/src/vue_components/config/ConfigSystem.vue
+++ b/client/src/vue_components/config/ConfigSystem.vue
@@ -1,8 +1,5 @@
-
+
@@ -24,12 +21,7 @@
>
Load Show
-
- Setup New Show
-
+ Setup New Show
@@ -41,10 +33,7 @@
{{ connectedClients.length }}
-
+
View Clients
@@ -61,15 +50,8 @@
@ok="onSubmit"
>
-
-
+
+
-
+
This is a required field and must be less than 100 characters.
-
+
-
+
This is a required field and must be before or the same as the end date.
-
+
-
+
This is a required field and must be after or the same as the start date.
-
+
Advanced Options
-
+
-
-
-
- Change the type of script for this show.
-
+
+
+ Change the type of script for this show.
-
- Cancel
-
-
+ Cancel
+
Save and Load
-
+
Save
-
+
-
+
Load Show
@@ -239,14 +173,8 @@
/>
-
-
+
+
@@ -300,13 +228,13 @@ export default {
},
start: {
required,
- beforeEnd: (value, vm) => (value == null && vm.end != null ? false
- : new Date(value) <= new Date(vm.end)),
+ beforeEnd: (value, vm) =>
+ value == null && vm.end != null ? false : new Date(value) <= new Date(vm.end),
},
end: {
required,
- afterStart: (value, vm) => (value == null && vm.start != null ? false
- : new Date(value) >= new Date(vm.start)),
+ afterStart: (value, vm) =>
+ value == null && vm.start != null ? false : new Date(value) >= new Date(vm.start),
},
script_mode: {
required,
@@ -315,8 +243,10 @@ export default {
},
computed: {
currentShowLoaded() {
- return (this.$store.state.system.settings.current_show != null
- && this.$store.state.currentShow != null);
+ return (
+ this.$store.state.system.settings.current_show != null &&
+ this.$store.state.currentShow != null
+ );
},
...mapGetters(['SCRIPT_MODES']),
},
diff --git a/client/src/vue_components/config/ConfigUsers.vue b/client/src/vue_components/config/ConfigUsers.vue
index 28d40dec..6d8fbd4f 100644
--- a/client/src/vue_components/config/ConfigUsers.vue
+++ b/client/src/vue_components/config/ConfigUsers.vue
@@ -1,23 +1,10 @@
-
+
-
+
-
- New User
-
+ New User
@@ -49,25 +36,10 @@
-
-
+
+
-
+
-
+
-
-
+
+
-
+
{{ scene.name }}
{{ CAST_BY_ID(data.item.Cast).first_name }} {{ CAST_BY_ID(data.item.Cast).last_name }}
-
-
+
+
{{ getLineCountForCast(data.item.Cast, scene.act, scene.id) }}
@@ -87,12 +70,15 @@ export default {
if (!this.loaded) {
return [];
}
- return this.CAST_LIST.map((cast) => ({
- Cast: cast.id,
- }), this);
+ return this.CAST_LIST.map(
+ (cast) => ({
+ Cast: cast.id,
+ }),
+ this
+ );
},
tableFields() {
- return ['Cast', ...this.sortedScenes.map((scene) => (scene.id.toString()))];
+ return ['Cast', ...this.sortedScenes.map((scene) => scene.id.toString())];
},
...mapGetters(['CAST_BY_ID', 'CAST_LIST']),
},
diff --git a/client/src/vue_components/show/config/characters/CharacterLineStats.vue b/client/src/vue_components/show/config/characters/CharacterLineStats.vue
index 1aa9e36f..87e8f14f 100644
--- a/client/src/vue_components/show/config/characters/CharacterLineStats.vue
+++ b/client/src/vue_components/show/config/characters/CharacterLineStats.vue
@@ -1,18 +1,9 @@
-
+
-
-
+
+
-
+
{{ scene.name }}
{{ CHARACTER_BY_ID(data.item.Character).name }}
-
+
@@ -87,12 +72,15 @@ export default {
if (!this.loaded) {
return [];
}
- return this.CHARACTER_LIST.map((character) => ({
- Character: character.id,
- }), this);
+ return this.CHARACTER_LIST.map(
+ (character) => ({
+ Character: character.id,
+ }),
+ this
+ );
},
tableFields() {
- return ['Character', ...this.sortedScenes.map((scene) => (scene.id.toString()))];
+ return ['Character', ...this.sortedScenes.map((scene) => scene.id.toString())];
},
...mapGetters(['CHARACTER_BY_ID', 'CHARACTER_LIST']),
},
diff --git a/client/src/vue_components/show/config/cues/CueCountStats.vue b/client/src/vue_components/show/config/cues/CueCountStats.vue
index 0eed9e53..df5277fb 100644
--- a/client/src/vue_components/show/config/cues/CueCountStats.vue
+++ b/client/src/vue_components/show/config/cues/CueCountStats.vue
@@ -1,18 +1,9 @@
-
+
-
-
+
+
-
+
{{ scene.name }}
{{ CUE_TYPE_BY_ID(data.item.CueType).prefix }}
-
-
+
+
{{ getCountForCueType(data.item.CueType, scene.act, scene.id) }}
@@ -87,12 +70,15 @@ export default {
if (!this.loaded) {
return [];
}
- return this.CUE_TYPES.map((cueType) => ({
- CueType: cueType.id,
- }), this);
+ return this.CUE_TYPES.map(
+ (cueType) => ({
+ CueType: cueType.id,
+ }),
+ this
+ );
},
tableFields() {
- return ['Cues', ...this.sortedScenes.map((scene) => (scene.id.toString()))];
+ return ['Cues', ...this.sortedScenes.map((scene) => scene.id.toString())];
},
...mapGetters(['CUE_TYPES', 'CUE_TYPE_BY_ID']),
},
diff --git a/client/src/vue_components/show/config/cues/CueEditor.vue b/client/src/vue_components/show/config/cues/CueEditor.vue
index 19637618..4c47902f 100644
--- a/client/src/vue_components/show/config/cues/CueEditor.vue
+++ b/client/src/vue_components/show/config/cues/CueEditor.vue
@@ -1,60 +1,30 @@
-
+
-
- Go to Page
-
-
- Go to Cue
-
+ Go to Page
+ Go to Cue
-
-
+
+
Prev Page
Current Page: {{ currentEditPage }}
-
-
- Next Page
-
+
+ Next Page
-
- Cues
-
+ Cues
Script
-
+
@@ -116,12 +86,7 @@
@ok="goToPage"
>
-
+
-
+
This is a required field, and must be greater than 0.
@@ -196,10 +159,24 @@ export default {
}
return 'primary';
},
- ...mapGetters(['CURRENT_SHOW', 'ACT_LIST', 'SCENE_LIST', 'CHARACTER_LIST',
- 'CHARACTER_GROUP_LIST', 'CAN_REQUEST_EDIT', 'CURRENT_EDITOR', 'INTERNAL_UUID',
- 'GET_SCRIPT_PAGE', 'DEBUG_MODE_ENABLED', 'CUE_TYPES', 'SCRIPT_CUES', 'SCRIPT_CUTS',
- 'STAGE_DIRECTION_STYLES', 'STAGE_DIRECTION_STYLE_OVERRIDES', 'CURRENT_USER']),
+ ...mapGetters([
+ 'CURRENT_SHOW',
+ 'ACT_LIST',
+ 'SCENE_LIST',
+ 'CHARACTER_LIST',
+ 'CHARACTER_GROUP_LIST',
+ 'CAN_REQUEST_EDIT',
+ 'CURRENT_EDITOR',
+ 'INTERNAL_UUID',
+ 'GET_SCRIPT_PAGE',
+ 'DEBUG_MODE_ENABLED',
+ 'CUE_TYPES',
+ 'SCRIPT_CUES',
+ 'SCRIPT_CUTS',
+ 'STAGE_DIRECTION_STYLES',
+ 'STAGE_DIRECTION_STYLE_OVERRIDES',
+ 'CURRENT_USER',
+ ]),
},
watch: {
currentEditPage(val) {
@@ -308,11 +285,25 @@ export default {
this.$bvModal.hide('jump-to-cue');
},
...mapMutations(['REMOVE_PAGE', 'ADD_BLANK_LINE', 'SET_LINE']),
- ...mapActions(['GET_SCENE_LIST', 'GET_ACT_LIST', 'GET_CHARACTER_LIST',
- 'GET_CHARACTER_GROUP_LIST', 'LOAD_SCRIPT_PAGE', 'ADD_BLANK_PAGE', 'GET_SCRIPT_CONFIG_STATUS',
- 'RESET_TO_SAVED', 'SAVE_NEW_PAGE', 'SAVE_CHANGED_PAGE', 'GET_CUE_TYPES', 'LOAD_CUES',
- 'GET_CUTS', 'GET_STAGE_DIRECTION_STYLES', 'GET_STAGE_DIRECTION_STYLE_OVERRIDES',
- 'GET_CUE_COLOUR_OVERRIDES', 'GET_CURRENT_USER']),
+ ...mapActions([
+ 'GET_SCENE_LIST',
+ 'GET_ACT_LIST',
+ 'GET_CHARACTER_LIST',
+ 'GET_CHARACTER_GROUP_LIST',
+ 'LOAD_SCRIPT_PAGE',
+ 'ADD_BLANK_PAGE',
+ 'GET_SCRIPT_CONFIG_STATUS',
+ 'RESET_TO_SAVED',
+ 'SAVE_NEW_PAGE',
+ 'SAVE_CHANGED_PAGE',
+ 'GET_CUE_TYPES',
+ 'LOAD_CUES',
+ 'GET_CUTS',
+ 'GET_STAGE_DIRECTION_STYLES',
+ 'GET_STAGE_DIRECTION_STYLE_OVERRIDES',
+ 'GET_CUE_COLOUR_OVERRIDES',
+ 'GET_CURRENT_USER',
+ ]),
},
};
diff --git a/client/src/vue_components/show/config/cues/JumpToCueModal.vue b/client/src/vue_components/show/config/cues/JumpToCueModal.vue
index 24fcc22d..38385937 100644
--- a/client/src/vue_components/show/config/cues/JumpToCueModal.vue
+++ b/client/src/vue_components/show/config/cues/JumpToCueModal.vue
@@ -12,11 +12,7 @@
@hidden="resetCueSearch"
>
-
+
-
+
{{ generalError }}
-
-
-
- Searching for cue...
-
+
-
Found {{ cueSearchResults.exact_matches.length }} cues matching "{{ cueSearchForm.identifier }}":
+
+ Found {{ cueSearchResults.exact_matches.length }} cues matching "{{
+ cueSearchForm.identifier
+ }}":
+
-
+
No exact match found for "{{ cueSearchForm.identifier }}"
Did you mean one of these?
@@ -105,10 +90,7 @@
>
{{ suggestion.cue_type.prefix }} {{ suggestion.cue.ident }}
- Page {{ suggestion.location.page }}
-
+
{{ Math.round(suggestion.similarity_score * 100) }}% match
@@ -117,31 +99,15 @@
-
+
No cues found matching "{{ cueSearchForm.identifier }}" for the selected cue type.
-
-
- New Search
-
-
- Cancel
-
+
+ New Search
+ Cancel
@@ -195,14 +161,14 @@ export default {
},
showSuggestions() {
return (
- this.cueSearchResults?.exact_matches?.length === 0
- && this.cueSearchResults?.suggestions?.length > 0
+ this.cueSearchResults?.exact_matches?.length === 0 &&
+ this.cueSearchResults?.suggestions?.length > 0
);
},
noMatches() {
return (
- this.cueSearchResults?.exact_matches?.length === 0
- && this.cueSearchResults?.suggestions?.length === 0
+ this.cueSearchResults?.exact_matches?.length === 0 &&
+ this.cueSearchResults?.suggestions?.length === 0
);
},
cueTypeErrorState() {
@@ -265,7 +231,7 @@ export default {
const targetPage = match.location.page;
this.$emit('navigate', targetPage);
Vue.$toast.success(
- `Jumped to ${match.cue_type.prefix} ${match.cue.ident} on page ${targetPage}`,
+ `Jumped to ${match.cue_type.prefix} ${match.cue.ident} on page ${targetPage}`
);
},
resetCueSearch() {
diff --git a/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue b/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue
index 42c32841..9884189e 100644
--- a/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue
+++ b/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue
@@ -1,30 +1,23 @@
-
+
- {{ actLabel }} - {{ sceneLabel }}
+ {{ actLabel }} - {{ sceneLabel }}
-
-
+
+
{{ cueLabel(cue) }}
@@ -47,29 +40,26 @@
>
- {{ characters.find((char) => (char.id === part.character_id)).name }}
+ {{ characters.find((char) => char.id === part.character_id).name }}
- {{ characterGroups.find((char) => (char.id === part.character_group_id)).name }}
+ {{ characterGroups.find((char) => char.id === part.character_group_id).name }}
{{ part.line_text }}
-
+
-
-
-
- Cue Line
-
+
+
+ Cue Line
-
-
-
- Spacing Line
-
+
+
+ Spacing Line
@@ -133,15 +101,8 @@
@hidden="resetNewForm"
@ok="onSubmitNew"
>
-
-
+
+
-
+
This is a required field.
-
+
-
+
This is a required field.
-
+
⚠️ A cue with this identifier already exists for this cue type
-
-
-
+
+
+
@@ -197,16 +147,22 @@
>
{{ line.line_parts[0].line_text | uppercase }}
{{ line.line_parts[0].line_text | lowercase }}
@@ -223,15 +179,15 @@
:style="headingStyle"
>
- {{ characters.find((char) => (char.id === part.character_id)).name }}
+ {{ characters.find((char) => char.id === part.character_id).name }}
- {{ characterGroups.find((char) => (char.id === part.character_group_id)).name }}
+ {{ characterGroups.find((char) => char.id === part.character_group_id).name }}
{{ part.line_text }}
@@ -251,15 +207,8 @@
@hidden="resetEditForm"
@ok="onSubmitEdit"
>
-
-
+
+
-
+
This is a required field.
-
+
-
+
This is a required field.
-
+
⚠️ A cue with this identifier already exists for this cue type
@@ -434,36 +372,60 @@ export default {
},
},
computed: {
- ...mapGetters(['IS_CUE_EDITOR', 'RBAC_ROLES', 'CURRENT_USER_RBAC', 'IS_ADMIN_USER', 'SCRIPT_CUES', 'CUE_COLOUR_OVERRIDES']),
+ ...mapGetters([
+ 'IS_CUE_EDITOR',
+ 'RBAC_ROLES',
+ 'CURRENT_USER_RBAC',
+ 'IS_ADMIN_USER',
+ 'SCRIPT_CUES',
+ 'CUE_COLOUR_OVERRIDES',
+ ]),
cueTypeOptions() {
if (this.IS_ADMIN_USER) {
return [
{ value: null, text: 'N/A' },
- ...this.cueTypes.map((cueType) => ({ value: cueType.id, text: `${cueType.prefix}: ${cueType.description}` })),
+ ...this.cueTypes.map((cueType) => ({
+ value: cueType.id,
+ text: `${cueType.prefix}: ${cueType.description}`,
+ })),
];
}
const writeMask = this.RBAC_ROLES.find((x) => x.key === 'WRITE').value;
-
- const allowableCueTypes = this.CURRENT_USER_RBAC.cuetypes.filter((x) => (x[1] & writeMask) !== 0).map((x) => x[0].id);
+
+ const allowableCueTypes = this.CURRENT_USER_RBAC.cuetypes
+ .filter((x) => (x[1] & writeMask) !== 0)
+ .map((x) => x[0].id);
return [
{ value: null, text: 'N/A' },
- ...this.cueTypes.filter((cueType) => allowableCueTypes.includes(cueType.id)).map((cueType) => ({ value: cueType.id, text: `${cueType.prefix}: ${cueType.description}` })),
+ ...this.cueTypes
+ .filter((cueType) => allowableCueTypes.includes(cueType.id))
+ .map((cueType) => ({
+ value: cueType.id,
+ text: `${cueType.prefix}: ${cueType.description}`,
+ })),
];
},
needsHeadings() {
const ret = [];
this.line.line_parts.forEach(function checkLinePartNeedsHeading(part) {
- if (this.previousLine == null
- || this.previousLine.line_parts.length !== this.line.line_parts.length) {
+ if (
+ this.previousLine == null ||
+ this.previousLine.line_parts.length !== this.line.line_parts.length
+ ) {
ret.push(true);
} else {
- const matchingIndex = this.previousLine.line_parts.find((prevPart) => (
- prevPart.part_index === part.part_index));
+ const matchingIndex = this.previousLine.line_parts.find(
+ (prevPart) => prevPart.part_index === part.part_index
+ );
if (matchingIndex == null) {
ret.push(true);
} else {
- ret.push(!(matchingIndex.character_id === part.character_id
- && matchingIndex.character_group_id === part.character_group_id));
+ ret.push(
+ !(
+ matchingIndex.character_id === part.character_id &&
+ matchingIndex.character_group_id === part.character_group_id
+ )
+ );
}
}
}, this);
@@ -473,26 +435,35 @@ export default {
if (this.previousLine == null) {
return true;
}
- return !(this.previousLine.act_id === this.line.act_id
- && this.previousLine.scene_id === this.line.scene_id);
+ return !(
+ this.previousLine.act_id === this.line.act_id &&
+ this.previousLine.scene_id === this.line.scene_id
+ );
},
flatScriptCues() {
- return Object.keys(this.SCRIPT_CUES).map((key) => this.SCRIPT_CUES[key]).flat();
+ return Object.keys(this.SCRIPT_CUES)
+ .map((key) => this.SCRIPT_CUES[key])
+ .flat();
},
isDuplicateNewCue() {
if (this.newFormState.ident == null || this.newFormState.cueType == null) {
return false;
}
- return this.flatScriptCues.some((cue) => cue.cue_type_id === this.newFormState.cueType
- && cue.ident === this.newFormState.ident);
+ return this.flatScriptCues.some(
+ (cue) =>
+ cue.cue_type_id === this.newFormState.cueType && cue.ident === this.newFormState.ident
+ );
},
isDuplicateEditCue() {
if (this.editFormState.ident == null || this.editFormState.cueType == null) {
return false;
}
- return this.flatScriptCues.some((cue) => cue.cue_type_id === this.editFormState.cueType
- && cue.ident === this.editFormState.ident
- && cue.id !== this.editFormState.cueId);
+ return this.flatScriptCues.some(
+ (cue) =>
+ cue.cue_type_id === this.editFormState.cueType &&
+ cue.ident === this.editFormState.ident &&
+ cue.id !== this.editFormState.cueId
+ );
},
},
methods: {
@@ -608,7 +579,7 @@ export default {
}
},
cueLabel(cue) {
- const cueType = this.cueTypes.find((cT) => (cT.id === cue.cue_type_id));
+ const cueType = this.cueTypes.find((cT) => cT.id === cue.cue_type_id);
return `${cueType.prefix} ${cue.ident}`;
},
cueBackgroundColour(cue) {
@@ -632,17 +603,17 @@ export default {
diff --git a/client/src/vue_components/show/config/mics/MicAllocations.vue b/client/src/vue_components/show/config/mics/MicAllocations.vue
index 46f6cd31..83e0b794 100644
--- a/client/src/vue_components/show/config/mics/MicAllocations.vue
+++ b/client/src/vue_components/show/config/mics/MicAllocations.vue
@@ -1,8 +1,5 @@
-
+
-
+
-
+
-
- View
-
-
- Edit
-
+ View
+ Edit
@@ -123,26 +107,15 @@
-
+
{{ scene.name }}
{{ CHARACTER_BY_ID(data.item.Character).name }}
-
+
-
- N/A
-
+ N/A
-
+
{{ getTooltipText(data.item.Character, scene.id) }}
@@ -212,7 +182,7 @@ export default {
];
},
tableFields() {
- return ['Character', ...this.sortedScenes.map((scene) => (scene.id.toString()))];
+ return ['Character', ...this.sortedScenes.map((scene) => scene.id.toString())];
},
sortedActs() {
if (this.CURRENT_SHOW.first_act_id == null) {
@@ -254,9 +224,12 @@ export default {
if (!this.loaded) {
return [];
}
- return this.CHARACTER_LIST.map((character) => ({
- Character: character.id,
- }), this);
+ return this.CHARACTER_LIST.map(
+ (character) => ({
+ Character: character.id,
+ }),
+ this
+ );
},
allAllocations() {
const micData = {};
@@ -266,11 +239,13 @@ export default {
allocations.forEach((allocation) => {
sceneData[allocation.scene_id] = allocation.character_id;
});
- this.sortedScenes.map((scene) => (scene.id)).forEach((sceneId) => {
- if (!Object.keys(sceneData).includes(sceneId.toString())) {
- sceneData[sceneId] = null;
- }
- });
+ this.sortedScenes
+ .map((scene) => scene.id)
+ .forEach((sceneId) => {
+ if (!Object.keys(sceneData).includes(sceneId.toString())) {
+ sceneData[sceneId] = null;
+ }
+ });
micData[micId] = sceneData;
}, this);
return micData;
@@ -284,21 +259,25 @@ export default {
allocationByCharacter() {
const charData = {};
// Initialize with empty arrays for each character/scene combination
- this.CHARACTER_LIST.map((character) => (character.id)).forEach((characterId) => {
+ this.CHARACTER_LIST.map((character) => character.id).forEach((characterId) => {
const sceneData = {};
- this.sortedScenes.map((scene) => (scene.id)).forEach((sceneId) => {
- sceneData[sceneId] = [];
- });
+ this.sortedScenes
+ .map((scene) => scene.id)
+ .forEach((sceneId) => {
+ sceneData[sceneId] = [];
+ });
charData[characterId] = sceneData;
}, this);
// Collect all mics assigned to each character in each scene
Object.keys(this.MIC_ALLOCATIONS).forEach((micId) => {
- this.sortedScenes.map((scene) => (scene.id)).forEach((sceneId) => {
- if (this.allAllocations[micId][sceneId] != null) {
- const characterId = this.allAllocations[micId][sceneId];
- charData[characterId][sceneId].push(this.MICROPHONE_BY_ID(micId).name);
- }
- }, this);
+ this.sortedScenes
+ .map((scene) => scene.id)
+ .forEach((sceneId) => {
+ if (this.allAllocations[micId][sceneId] != null) {
+ const characterId = this.allAllocations[micId][sceneId];
+ charData[characterId][sceneId].push(this.MICROPHONE_BY_ID(micId).name);
+ }
+ }, this);
}, this);
// Convert arrays to comma-separated strings (or null if empty)
Object.keys(charData).forEach((characterId) => {
@@ -309,9 +288,19 @@ export default {
});
return charData;
},
- ...mapGetters(['MICROPHONES', 'CURRENT_SHOW', 'ACT_BY_ID', 'SCENE_BY_ID', 'CHARACTER_LIST',
- 'CHARACTER_BY_ID', 'MIC_ALLOCATIONS', 'MICROPHONE_BY_ID', 'IS_SHOW_EDITOR',
- 'CONFLICTS_BY_SCENE', 'CONFLICTS_BY_MIC']),
+ ...mapGetters([
+ 'MICROPHONES',
+ 'CURRENT_SHOW',
+ 'ACT_BY_ID',
+ 'SCENE_BY_ID',
+ 'CHARACTER_LIST',
+ 'CHARACTER_BY_ID',
+ 'MIC_ALLOCATIONS',
+ 'MICROPHONE_BY_ID',
+ 'IS_SHOW_EDITOR',
+ 'CONFLICTS_BY_SCENE',
+ 'CONFLICTS_BY_MIC',
+ ]),
},
async mounted() {
await this.resetToStoredAlloc();
@@ -378,8 +367,10 @@ export default {
}
// Check this mic isn't allocated to anyone else for this scene
- if (this.internalState[micId][sceneId] != null
- && this.internalState[micId][sceneId] !== characterId) {
+ if (
+ this.internalState[micId][sceneId] != null &&
+ this.internalState[micId][sceneId] !== characterId
+ ) {
return true;
}
@@ -406,8 +397,9 @@ export default {
}
// Find all conflicts where this scene is the "change INTO" scene for this character
- return allConflicts.filter((c) => c.adjacentSceneId === sceneId
- && c.adjacentCharacterId === characterId);
+ return allConflicts.filter(
+ (c) => c.adjacentSceneId === sceneId && c.adjacentCharacterId === characterId
+ );
},
getConflictClassForCell(characterId, sceneId) {
const conflicts = this.getConflictsForCell(characterId, sceneId);
@@ -455,8 +447,8 @@ export default {
+
diff --git a/client/src/vue_components/show/config/mics/MicList.vue b/client/src/vue_components/show/config/mics/MicList.vue
index 1b6e1cbf..c318f328 100644
--- a/client/src/vue_components/show/config/mics/MicList.vue
+++ b/client/src/vue_components/show/config/mics/MicList.vue
@@ -9,22 +9,13 @@
show-empty
>
-
+
New Microphone
-
- Edit
-
+ Edit
-
-
+
+
-
+
This is a required field, and must be unique.
@@ -87,9 +69,7 @@
:state="validateNewMicrophone('description')"
aria-describedby="description-feedback"
/>
-
+
Something went wrong!
@@ -104,15 +84,8 @@
@hidden="resetEditMicrophoneForm"
@ok="onSubmitEditMicrophone"
>
-
-
+
+
-
+
This is a required field, and must be unique.
@@ -138,9 +109,7 @@
:state="validateEditMicrophone('description')"
aria-describedby="description-feedback"
/>
-
+
Something went wrong!
@@ -160,11 +129,12 @@ function isNameUnique(value) {
}
if (this.editMicrophoneForm.id != null) {
if (this.MICROPHONES != null && this.MICROPHONES.length > 0) {
- return !this.MICROPHONES.some((mic) => (
- mic.name === value && mic.id !== this.editMicrophoneForm.id));
+ return !this.MICROPHONES.some(
+ (mic) => mic.name === value && mic.id !== this.editMicrophoneForm.id
+ );
}
} else if (this.MICROPHONES != null && this.MICROPHONES.length > 0) {
- return !this.MICROPHONES.some((mic) => (mic.name === value));
+ return !this.MICROPHONES.some((mic) => mic.name === value);
}
return true;
}
@@ -173,11 +143,7 @@ export default {
name: 'MicList',
data() {
return {
- micFields: [
- 'name',
- 'description',
- { key: 'btn', label: '' },
- ],
+ micFields: ['name', 'description', { key: 'btn', label: '' }],
rowsPerPage: 15,
currentPage: 1,
newMicrophoneForm: {
@@ -200,16 +166,14 @@ export default {
required,
unique: isNameUnique,
},
- description: {
- },
+ description: {},
},
editMicrophoneForm: {
name: {
required,
unique: isNameUnique,
},
- description: {
- },
+ description: {},
},
},
computed: {
diff --git a/client/src/vue_components/show/config/mics/MicTimeline.vue b/client/src/vue_components/show/config/mics/MicTimeline.vue
index b3385ac4..67d487b7 100644
--- a/client/src/vue_components/show/config/mics/MicTimeline.vue
+++ b/client/src/vue_components/show/config/mics/MicTimeline.vue
@@ -1,15 +1,9 @@
-
+
-
+
@@ -49,30 +43,15 @@
-
+
No allocation data to display for this view
-
+
-
-
+
+
-
+
-
+
{{ bar.label }}
@@ -207,7 +180,10 @@ export default {
return {
viewMode: 'mic', // 'mic', 'character', or 'cast'
margin: {
- top: 75, right: 20, bottom: 20, left: 150,
+ top: 75,
+ right: 20,
+ bottom: 20,
+ left: 150,
},
sceneWidth: 100,
rowHeight: 50,
@@ -257,13 +233,11 @@ export default {
}));
}
// Cast-centric view
- return this.CAST_LIST
- .filter((cast) => this.hasCastAllocations(cast.id))
- .map((cast) => ({
- id: cast.id,
- name: `${cast.first_name} ${cast.last_name}`.trim() || `Cast ${cast.id}`,
- type: 'cast',
- }));
+ return this.CAST_LIST.filter((cast) => this.hasCastAllocations(cast.id)).map((cast) => ({
+ id: cast.id,
+ name: `${cast.first_name} ${cast.last_name}`.trim() || `Cast ${cast.id}`,
+ type: 'cast',
+ }));
},
contentWidth() {
return this.scenes.length * this.sceneWidth;
@@ -371,9 +345,7 @@ export default {
},
hasCastAllocations(castId) {
// Check if any character played by this cast member has allocations
- const castCharacters = this.characters.filter(
- (char) => char.cast_member?.id === castId,
- );
+ const castCharacters = this.characters.filter((char) => char.cast_member?.id === castId);
return castCharacters.some((char) => this.hasCharacterAllocations(char.id));
},
generateBarsForMic(micId, rowIndex, bars) {
@@ -448,7 +420,9 @@ export default {
let currentSegment = null;
this.scenes.forEach((scene, sceneIndex) => {
- const alloc = micAllocs.find((a) => a.scene_id === scene.id && a.character_id === characterId);
+ const alloc = micAllocs.find(
+ (a) => a.scene_id === scene.id && a.character_id === characterId
+ );
if (alloc) {
if (currentSegment && currentSegment.micId === parseInt(micId, 10)) {
@@ -508,9 +482,7 @@ export default {
},
generateBarsForCast(castId, rowIndex, bars) {
// Find all characters played by this cast member
- const castCharacters = this.characters.filter(
- (char) => char.cast_member?.id === castId,
- );
+ const castCharacters = this.characters.filter((char) => char.cast_member?.id === castId);
const segmentsByMic = new Map();
@@ -524,8 +496,10 @@ export default {
this.scenes.forEach((scene, sceneIndex) => {
// Check if any of this cast member's characters uses this mic in this scene
- const alloc = micAllocs.find((a) => a.scene_id === scene.id
- && castCharacters.some((char) => char.id === a.character_id));
+ const alloc = micAllocs.find(
+ (a) =>
+ a.scene_id === scene.id && castCharacters.some((char) => char.id === a.character_id)
+ );
if (alloc) {
if (currentSegment && currentSegment.micId === parseInt(micId, 10)) {
@@ -808,7 +782,9 @@ export default {
stroke: #212529;
stroke-width: 1px;
cursor: pointer;
- transition: opacity 0.2s ease, stroke-width 0.2s ease;
+ transition:
+ opacity 0.2s ease,
+ stroke-width 0.2s ease;
}
.allocation-bar:hover {
diff --git a/client/src/vue_components/show/config/mics/ResourceAvailability.vue b/client/src/vue_components/show/config/mics/ResourceAvailability.vue
index 868a6bfa..1e4a9801 100644
--- a/client/src/vue_components/show/config/mics/ResourceAvailability.vue
+++ b/client/src/vue_components/show/config/mics/ResourceAvailability.vue
@@ -1,21 +1,13 @@
-
+
-
+
@@ -24,33 +16,23 @@
{{ totalMicrophones }}
-
- Total Microphones
-
+
Total Microphones
{{ peakUsage }}
-
- Peak Simultaneous Usage
-
+
Peak Simultaneous Usage
{{ conflictCount }}
-
- Total Conflicts
-
+
Total Conflicts
-
- {{ utilizationRate }}%
-
-
- Average Utilization
-
+
{{ utilizationRate }}%
+
Average Utilization
@@ -71,18 +53,12 @@
-
+
No microphone allocation data to display
-