diff --git a/client/src/App.vue b/client/src/App.vue index f9cb80a4..317bc10d 100644 --- a/client/src/App.vue +++ b/client/src/App.vue @@ -80,6 +80,9 @@ + + Settings + Sign Out diff --git a/client/src/router/index.js b/client/src/router/index.js index 1e0275f8..73e94d12 100644 --- a/client/src/router/index.js +++ b/client/src/router/index.js @@ -84,7 +84,12 @@ const routes = [ { path: '/login', name: 'login', - component: () => import('../views/LoginView.vue'), + component: () => import('../views/user/LoginView.vue'), + }, + { + path: '/me', + name: 'user_settings', + component: () => import('../views/user/Settings.vue'), }, { path: '*', diff --git a/client/src/store/modules/user/settings.js b/client/src/store/modules/user/settings.js new file mode 100644 index 00000000..d4555c6d --- /dev/null +++ b/client/src/store/modules/user/settings.js @@ -0,0 +1,84 @@ +import Vue from 'vue'; +import log from 'loglevel'; + +import { makeURL } from '@/js/utils'; + +export default { + state: { + stageDirectionStyleOverrides: [], + }, + mutations: { + SET_STAGE_DIRECTION_STYLE_OVERRIDES(state, overrides) { + state.stageDirectionStyleOverrides = overrides; + }, + }, + actions: { + async GET_STAGE_DIRECTION_STYLE_OVERRIDES(context) { + const response = await fetch(`${makeURL('/api/v1/user/settings/stage_direction_overrides')}`, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }); + if (response.ok) { + const respJson = await response.json(); + context.commit('SET_STAGE_DIRECTION_STYLE_OVERRIDES', respJson.overrides); + } else { + log.error('Unable to load stage direction style overrides'); + } + }, + async ADD_STAGE_DIRECTION_STYLE_OVERRIDE(context, style) { + const response = await fetch(`${makeURL('/api/v1/user/settings/stage_direction_overrides')}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(style), + }); + if (response.ok) { + context.dispatch('GET_STAGE_DIRECTION_STYLE_OVERRIDES'); + Vue.$toast.success('Added new stage direction style override!'); + } else { + log.error('Unable to add new stage direction style override'); + Vue.$toast.error('Unable to add new stage direction style override'); + } + }, + async DELETE_STAGE_DIRECTION_STYLE_OVERRIDE(context, styleId) { + const response = await fetch(`${makeURL('/api/v1/user/settings/stage_direction_overrides')}`, { + method: 'DELETE', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ id: styleId }), + }); + if (response.ok) { + context.dispatch('GET_STAGE_DIRECTION_STYLE_OVERRIDES'); + Vue.$toast.success('Deleted stage direction style override!'); + } else { + log.error('Unable to delete stage direction style override'); + Vue.$toast.error('Unable to delete stage direction style override'); + } + }, + async UPDATE_STAGE_DIRECTION_STYLE_OVERRIDE(context, style) { + const response = await fetch(`${makeURL('/api/v1/user/settings/stage_direction_overrides')}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(style), + }); + if (response.ok) { + context.dispatch('GET_STAGE_DIRECTION_STYLE_OVERRIDES'); + Vue.$toast.success('Updated stage direction style override!'); + } else { + log.error('Unable to edit stage direction style override'); + Vue.$toast.error('Unable to edit stage direction style override'); + } + }, + }, + getters: { + STAGE_DIRECTION_STYLE_OVERRIDES(state) { + return state.stageDirectionStyleOverrides; + }, + }, +}; diff --git a/client/src/store/modules/user.js b/client/src/store/modules/user/user.js similarity index 98% rename from client/src/store/modules/user.js rename to client/src/store/modules/user/user.js index 84d2f698..9d69bf28 100644 --- a/client/src/store/modules/user.js +++ b/client/src/store/modules/user/user.js @@ -3,6 +3,7 @@ import log from 'loglevel'; import { makeURL } from '@/js/utils'; import router from '@/router'; +import settings from './settings'; export default { state: { @@ -129,4 +130,7 @@ export default { return state.currentRbac; }, }, + modules: { + settings, + }, }; diff --git a/client/src/store/store.js b/client/src/store/store.js index 5d3ba719..05fadbc8 100644 --- a/client/src/store/store.js +++ b/client/src/store/store.js @@ -4,7 +4,7 @@ import createPersistedState from 'vuex-persistedstate'; import log from 'loglevel'; import { makeURL } from '@/js/utils'; -import user from '@/store/modules/user'; +import user from '@/store/modules/user/user'; import websocket from './modules/websocket'; import system from './modules/system'; import show from './modules/show'; diff --git a/client/src/views/LoginView.vue b/client/src/views/user/LoginView.vue similarity index 100% rename from client/src/views/LoginView.vue rename to client/src/views/user/LoginView.vue diff --git a/client/src/views/user/Settings.vue b/client/src/views/user/Settings.vue new file mode 100644 index 00000000..205728a2 --- /dev/null +++ b/client/src/views/user/Settings.vue @@ -0,0 +1,34 @@ + + + diff --git a/client/src/vue_components/show/config/script/StageDirectionStyles.vue b/client/src/vue_components/show/config/script/StageDirectionStyles.vue index 8e9f7d53..659fa2e7 100644 --- a/client/src/vue_components/show/config/script/StageDirectionStyles.vue +++ b/client/src/vue_components/show/config/script/StageDirectionStyles.vue @@ -513,7 +513,7 @@ export default { event.preventDefault(); } else { await this.ADD_STAGE_DIRECTION_STYLE(this.createPayload); - this.resetNewCueTypeForm(); + this.resetNewFormState(); } }, async onSubmitEditStyle(event) { diff --git a/client/src/vue_components/user/settings/AboutUser.vue b/client/src/vue_components/user/settings/AboutUser.vue new file mode 100644 index 00000000..f12c2959 --- /dev/null +++ b/client/src/vue_components/user/settings/AboutUser.vue @@ -0,0 +1,40 @@ + + + diff --git a/client/src/vue_components/user/settings/StageDirectionStyles.vue b/client/src/vue_components/user/settings/StageDirectionStyles.vue new file mode 100644 index 00000000..249f50ae --- /dev/null +++ b/client/src/vue_components/user/settings/StageDirectionStyles.vue @@ -0,0 +1,589 @@ + + + diff --git a/server/alembic_config/versions/a39ac9e9f085_add_user_settings.py b/server/alembic_config/versions/a39ac9e9f085_add_user_settings.py new file mode 100644 index 00000000..065bd76d --- /dev/null +++ b/server/alembic_config/versions/a39ac9e9f085_add_user_settings.py @@ -0,0 +1,56 @@ +"""Add user settings + +Revision ID: a39ac9e9f085 +Revises: a44e01459595 +Create Date: 2025-04-19 11:37:43.556499 + +""" + +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +# revision identifiers, used by Alembic. +revision: str = "a39ac9e9f085" +down_revision: Union[str, None] = "a44e01459595" +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! ### + op.create_table( + "user_settings", + sa.Column("id", sa.Integer(), autoincrement=True, nullable=False), + sa.Column("user_id", sa.Integer(), nullable=True), + sa.Column("settings_type", sa.String(), nullable=True), + sa.Column("settings", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("updated_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], ["user.id"], name=op.f("fk_user_settings_user_id_user") + ), + sa.PrimaryKeyConstraint("id", name=op.f("pk_user_settings")), + ) + with op.batch_alter_table("user_settings", schema=None) as batch_op: + batch_op.create_index( + batch_op.f("ix_user_settings_settings_type"), + ["settings_type"], + unique=False, + ) + batch_op.create_index( + batch_op.f("ix_user_settings_user_id"), ["user_id"], unique=False + ) + + # ### 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_index(batch_op.f("ix_user_settings_user_id")) + batch_op.drop_index(batch_op.f("ix_user_settings_settings_type")) + + op.drop_table("user_settings") + # ### end Alembic commands ### diff --git a/server/controllers/api/user/__init__.py b/server/controllers/api/user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/controllers/api/user/settings.py b/server/controllers/api/user/settings.py new file mode 100644 index 00000000..76ae34c2 --- /dev/null +++ b/server/controllers/api/user/settings.py @@ -0,0 +1,172 @@ +from typing import List + +from tornado import escape, web + +from models.script import StageDirectionStyle +from models.user import UserSettings +from utils.web.base_controller import BaseAPIController +from utils.web.route import ApiRoute, ApiVersion + + +@ApiRoute("user/settings/stage_direction_overrides", ApiVersion.V1) +class UserStageDirectionOverridesController(BaseAPIController): + @web.authenticated + def get(self): + with self.make_session() as session: + overrides: List[UserSettings] = UserSettings.get_by_type( + self.current_user["id"], StageDirectionStyle, session + ) + self.set_status(200) + self.finish( + { + "overrides": [ + {"id": override.id, "settings": override.settings_dict} + for override in overrides + ] + } + ) + + @web.authenticated + async def post(self): + data = escape.json_decode(self.request.body) + style_id: str = data.get("styleId", None) + if not style_id: + self.set_status(400) + await self.finish({"message": "Style ID missing"}) + return + + bold: bool = data.get("bold", False) + italic: bool = data.get("italic", False) + underline: bool = data.get("underline", False) + + text_format: str = data.get("textFormat", None) + if not text_format or text_format not in ["default", "upper", "lower"]: + self.set_status(400) + await self.finish({"message": "Text format missing or invalid"}) + return + + text_colour: str = data.get("textColour", None) + if not text_colour: + self.set_status(400) + await self.finish({"message": "Text colour missing"}) + return + + enable_background_colour: bool = data.get("enableBackgroundColour", False) + background_colour: str = data.get("backgroundColour", None) + if enable_background_colour and not background_colour: + self.set_status(400) + await self.finish({"message": "Background colour missing"}) + return + + with self.make_session() as session: + style_to_override = session.query(StageDirectionStyle).get(style_id) + if not style_to_override: + self.set_status(404) + await self.finish({"message": "Stage direction style not found"}) + return + + user_style = UserSettings.create_for_user( + user_id=self.current_user["id"], + settings_type="stage_direction_styles", + settings_data={ + "id": style_id, + "bold": bold, + "italic": italic, + "underline": underline, + "text_format": text_format, + "text_colour": text_colour, + "enable_background_colour": enable_background_colour, + "background_colour": background_colour, + }, + ) + session.add(user_style) + session.commit() + + self.set_status(200) + await self.finish( + { + "id": user_style.id, + "message": "Successfully added stage direction style override", + } + ) + + await self.application.ws_send_to_user( + self.current_user["id"], + "NOOP", + "GET_STAGE_DIRECTION_STYLE_OVERRIDES", + {}, + ) + + @web.authenticated + async def patch(self): + data = escape.json_decode(self.request.body) + settings_id = data.get("id", None) + if not settings_id: + self.set_status(400) + await self.finish({"message": "ID missing"}) + return + + with self.make_session() as session: + entry: UserSettings = session.get(UserSettings, settings_id) + if entry: + if entry.user_id != self.current_user["id"]: + self.set_status(403) + await self.finish() + + merge_settings = data.copy() + del merge_settings["id"] + entry.update_settings(merge_settings) + session.commit() + + self.set_status(200) + await self.finish( + {"message": "Successfully edited stage direction style override"} + ) + + await self.application.ws_send_to_user( + self.current_user["id"], + "NOOP", + "GET_STAGE_DIRECTION_STYLE_OVERRIDES", + {}, + ) + else: + self.set_status(404) + await self.finish( + {"message": "Stage direction style override not found"} + ) + + @web.authenticated + async def delete(self): + data = escape.json_decode(self.request.body) + with self.make_session() as session: + settings_id = data.get("id", None) + if not settings_id: + self.set_status(400) + await self.finish({"message": "ID missing"}) + return + + entry: UserSettings = session.get(UserSettings, settings_id) + if entry: + if entry.user_id != self.current_user["id"]: + self.set_status(403) + await self.finish() + + session.delete(entry) + session.commit() + + self.set_status(200) + await self.finish( + {"message": "Successfully deleted stage direction style override"} + ) + + await self.application.ws_send_to_user( + self.current_user["id"], + "NOOP", + "GET_STAGE_DIRECTION_STYLE_OVERRIDES", + {}, + ) + else: + self.set_status(404) + await self.finish( + {"message": "Stage direction style override not found"} + ) diff --git a/server/digi_server/app_server.py b/server/digi_server/app_server.py index b3ae4186..39867d32 100644 --- a/server/digi_server/app_server.py +++ b/server/digi_server/app_server.py @@ -289,7 +289,7 @@ async def _show_changed(self): def get_db(self) -> DigiSQLAlchemy: return self._db - def get_all_ws(self, user_id) -> List[WebSocketController]: + def get_all_ws(self, user_id: int) -> List[WebSocketController]: sockets = [] for client in self.clients: c_user_id = client.get_secure_cookie("digiscript_user_id") @@ -308,3 +308,11 @@ async def ws_send_to_all(self, ws_op: str, ws_action: str, ws_data: dict): await client.write_message( {"OP": ws_op, "DATA": ws_data, "ACTION": ws_action} ) + + async def ws_send_to_user( + self, user_id: int, ws_op: str, ws_action: str, ws_data: dict + ): + for client in self.get_all_ws(user_id): + await client.write_message( + {"OP": ws_op, "DATA": ws_data, "ACTION": ws_action} + ) diff --git a/server/models/script.py b/server/models/script.py index 39d33324..24ff890e 100644 --- a/server/models/script.py +++ b/server/models/script.py @@ -2,6 +2,8 @@ from sqlalchemy.orm import backref, relationship from models.models import db +from models.user import UserSettings +from registry.user_settings import UserSettingsRegistry from utils.database import DeleteMixin @@ -139,7 +141,18 @@ class ScriptCuts(db.Model): ) -class StageDirectionStyle(db.Model): +@UserSettingsRegistry.register( + settings_fields=[ + "bold", + "italic", + "underline", + "text_format", + "text_colour", + "enable_background_colour", + "background_colour", + ] +) +class StageDirectionStyle(db.Model, DeleteMixin): __tablename__ = "stage_direction_styles" id = Column(Integer, primary_key=True, autoincrement=True) @@ -162,3 +175,15 @@ class StageDirectionStyle(db.Model): "stage_direction_styles", uselist=True, cascade="all, delete-orphan" ), ) + + def pre_delete(self, session): + user_overrides = ( + session.query(UserSettings) + .filter(UserSettings.settings_type == self.__tablename__) + .all() + ) + for override in user_overrides: + session.delete(override) + + def post_delete(self, session): + pass diff --git a/server/models/user.py b/server/models/user.py index 45ae8c69..8729be5d 100644 --- a/server/models/user.py +++ b/server/models/user.py @@ -1,6 +1,12 @@ -from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String +import datetime +import json +from functools import partial +from typing import Union + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text from models.models import db +from registry.user_settings import UserSettingsRegistry class User(db.Model): @@ -12,3 +18,76 @@ class User(db.Model): show_id = Column(Integer(), ForeignKey("shows.id"), index=True) is_admin = Column(Boolean()) last_login = Column(DateTime()) + + +class UserSettings(db.Model): + __tablename__ = "user_settings" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("user.id"), index=True) + + settings_type = Column(String, index=True) + settings = Column(Text) + + created_at = Column( + DateTime, default=partial(datetime.datetime.now, tz=datetime.timezone.utc) + ) + updated_at = Column( + DateTime, + default=partial(datetime.datetime.now, tz=datetime.timezone.utc), + onupdate=partial(datetime.datetime.now, tz=datetime.timezone.utc), + ) + + @property + def settings_dict(self): + """Return settings as a Python dictionary""" + return json.loads(self.settings) if self.settings else {} + + def update_settings(self, new_settings): + """Update settings with validation""" + # Validate the complete set of settings that would result from this update + current = self.settings_dict + merged = current.copy() + merged.update(new_settings) + + errors = UserSettingsRegistry.validate(self.settings_type, merged) + if errors: + raise ValueError(f"Invalid settings: {', '.join(errors)}") + + # Apply the update + self.settings = json.dumps(merged) + self.updated_at = datetime.datetime.now(tz=datetime.timezone.utc) + + @classmethod + def get_by_type(cls, user_id, settings_type: Union[db.Model, str], session): + if issubclass(settings_type, db.Model): + settings_type = settings_type.__tablename__ + if not UserSettingsRegistry.is_registered(settings_type): + return [] + return ( + session.query(UserSettings) + .filter_by(user_id=user_id) + .filter_by(settings_type=settings_type) + .all() + ) + + @classmethod + def create_for_user(cls, user_id, settings_type, settings_data): + """Create settings with validation""" + errors = UserSettingsRegistry.validate(settings_type, settings_data) + if errors: + raise ValueError(f"Invalid settings: {', '.join(errors)}") + + settings = cls( + user_id=user_id, + settings_type=settings_type, + settings=json.dumps(settings_data), + ) + + return settings + + @classmethod + def get_default_settings(cls, settings_type): + """Get default settings from registered model""" + model_class = UserSettingsRegistry.registry()[settings_type]["model"] + return model_class.to_settings_dict() diff --git a/server/registry/__init__.py b/server/registry/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/registry/user_settings.py b/server/registry/user_settings.py new file mode 100644 index 00000000..3d766e00 --- /dev/null +++ b/server/registry/user_settings.py @@ -0,0 +1,150 @@ +from sqlalchemy import Boolean, DateTime, Float, Integer, String, Text + +from models.models import db + + +class UserSettingsRegistry: + _registry = {} + + # Type mapping for SQLAlchemy column types to Python types + _type_map = { + String: {"name": "String", "python_type": str}, + Text: {"name": "String", "python_type": str}, + Integer: {"name": "Integer", "python_type": int}, + Boolean: {"name": "Boolean", "python_type": bool}, + Float: {"name": "Float", "python_type": float}, + DateTime: {"name": "DateTime", "python_type": str}, + } + + @classmethod + def registry(cls): + return cls._registry + + @classmethod + def register(cls, settings_fields=None): + def decorator(model_class): + # Use the model's __tablename__ as the settings type identifier + settings_type = model_class.__tablename__ + + # Raise an exception if settings_fields is None + if settings_fields is None: + raise ValueError( + f"settings_fields must be provided for {model_class.__name__}" + ) + + # Get primary key fields + primary_key_fields = [ + c.name for c in model_class.__table__.columns if c.primary_key + ] + + # Extract schema + schema = cls._extract_schema( + model_class, primary_key_fields, settings_fields + ) + + # Store schema in registry + cls._registry[settings_type] = { + "model": model_class, + "schema": schema, + "primary_key_fields": primary_key_fields, + "settings_fields": settings_fields, + } + + # Add class method for conversion to settings + @classmethod + def to_settings_dict(_model_cls, instance=None): + result = {} + + # Include all fields from schema + for field_name in cls._registry[settings_type]["schema"].keys(): + if instance and hasattr(instance, field_name): + # Get the value from the instance + result[field_name] = getattr(instance, field_name) + else: + # Get default from schema + schema_info = cls._registry[settings_type]["schema"][field_name] + result[field_name] = schema_info["default"] + return result + + # Add the method to the model class + model_class.to_settings_dict = to_settings_dict + + return model_class + + return decorator + + @classmethod + def _get_column_type_info(cls, column): + column_type = type(column.type) + + # Use the actual column type, if this fails then we need to add + # support for that column type + return cls._type_map[column_type] + + @classmethod + def _extract_schema(cls, model_class, primary_key_fields, additional_fields): + schema = {} + + # Include primary key fields and additional fields + fields_to_include = primary_key_fields + additional_fields + + for column_name, column in model_class.__table__.columns.items(): + if column_name in fields_to_include: + type_info = cls._get_column_type_info(column) + + schema[column_name] = { + "type_name": type_info["name"], + "python_type": type_info["python_type"], + "nullable": column.nullable, + "default": ( + column.default.arg if column.default is not None else None + ), + "primary_key": column.primary_key, + } + + # Make sure we have the same number of fields in the schema as we do fields requested by + # the registration + if len(schema) != len(fields_to_include): + raise ValueError( + f"Schema mismatch for {model_class.__name__}. Not all fields included." + ) + + return schema + + @classmethod + def get_schema(cls, settings_type): + if settings_type not in cls._registry: + raise ValueError(f"Settings type '{settings_type}' not registered") + return cls._registry[settings_type]["schema"] + + @classmethod + def validate(cls, settings_type, data): + if settings_type not in cls._registry: + raise ValueError(f"Settings type '{settings_type}' not registered") + + schema = cls._registry[settings_type]["schema"] + errors = [] + + # Check that all fields are present + for field in schema: + if field not in data: + errors.append(f"Missing required field: {field}") + + # Validate field types + for field, value in data.items(): + if field in schema: + expected_type = schema[field]["python_type"] + if not isinstance(value, expected_type): + errors.append( + f"Field {field} must be a {schema[field]['type_name'].lower()}" + ) + else: + errors.append(f"Unknown field: {field}") + + return errors + + @classmethod + def is_registered(cls, settings_type): + if isinstance(settings_type, db.Model): + settings_type = settings_type.__tablename__ + return settings_type in cls._registry diff --git a/server/requirements.txt b/server/requirements.txt index 39eb177a..4d0050b5 100644 --- a/server/requirements.txt +++ b/server/requirements.txt @@ -8,3 +8,4 @@ tornado-prometheus==0.1.2 bcrypt==4.3.0 anytree==2.13.0 alembic==1.15.2 +marshmallow<4 \ No newline at end of file