diff --git a/spiffworkflow-backend/migrations/versions/6aa02463da9c_.py b/spiffworkflow-backend/migrations/versions/6aa02463da9c_.py new file mode 100644 index 000000000..ed75245d7 --- /dev/null +++ b/spiffworkflow-backend/migrations/versions/6aa02463da9c_.py @@ -0,0 +1,46 @@ +"""empty message + +Revision ID: 6aa02463da9c +Revises: 68adb1d504e1 +Create Date: 2023-05-04 12:50:07.979692 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6aa02463da9c' +down_revision = '68adb1d504e1' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('active_user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('last_visited_identifier', sa.String(length=255), nullable=False), + sa.Column('last_seen_in_seconds', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id', 'last_visited_identifier', name='user_last_visited_unique') + ) + with op.batch_alter_table('active_user', schema=None) as batch_op: + batch_op.create_index(batch_op.f('ix_active_user_last_seen_in_seconds'), ['last_seen_in_seconds'], unique=False) + batch_op.create_index(batch_op.f('ix_active_user_last_visited_identifier'), ['last_visited_identifier'], unique=False) + batch_op.create_index(batch_op.f('ix_active_user_user_id'), ['user_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('active_user', schema=None) as batch_op: + batch_op.drop_index(batch_op.f('ix_active_user_user_id')) + batch_op.drop_index(batch_op.f('ix_active_user_last_visited_identifier')) + batch_op.drop_index(batch_op.f('ix_active_user_last_seen_in_seconds')) + + op.drop_table('active_user') + # ### end Alembic commands ### diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml index 3ba2407b7..a43736292 100755 --- a/spiffworkflow-backend/src/spiffworkflow_backend/api.yml +++ b/spiffworkflow-backend/src/spiffworkflow_backend/api.yml @@ -163,6 +163,50 @@ paths: $ref: "#/components/schemas/OkTrue" + /active-users/updates/{last_visited_identifier}: + parameters: + - name: last_visited_identifier + in: path + required: true + description: The identifier for the last visited page for the user. + schema: + type: string + get: + tags: + - Active User + operationId: spiffworkflow_backend.routes.active_users_controller.active_user_updates + summary: An SSE (Server Sent Events) endpoint that returns what users are also currently viewing the same page as you. + responses: + "200": + description: list of users + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/User" + + /active-users/unregister/{last_visited_identifier}: + parameters: + - name: last_visited_identifier + in: path + required: true + description: The identifier for the last visited page for the user. + schema: + type: string + get: + tags: + - Active User + operationId: spiffworkflow_backend.routes.active_users_controller.active_user_unregister + summary: Unregisters a user from a page. To be used when the /active-users/updates endpoint is closed. + responses: + "200": + description: The current user has unregistered. + content: + application/json: + schema: + $ref: "#/components/schemas/OkTrue" + /process-groups: parameters: - name: process_group_identifier @@ -1708,6 +1752,7 @@ paths: application/json: schema: $ref: "#/components/schemas/ServiceTask" + /tasks/{process_instance_id}: parameters: - name: process_instance_id @@ -1729,7 +1774,6 @@ paths: schema: $ref: "#/components/schemas/Task" - /tasks/{process_instance_id}/{task_guid}: parameters: - name: task_guid diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py b/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py index 0a4686aff..8b9a63225 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/load_database_models.py @@ -68,5 +68,8 @@ from spiffworkflow_backend.models.bpmn_process_definition_relationship import ( from spiffworkflow_backend.models.process_instance_queue import ( ProcessInstanceQueueModel, ) # noqa: F401 +from spiffworkflow_backend.models.active_user import ( + ActiveUserModel, +) # noqa: F401 add_listeners() diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/models/active_user.py b/spiffworkflow-backend/src/spiffworkflow_backend/models/active_user.py new file mode 100644 index 000000000..400e51da9 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/models/active_user.py @@ -0,0 +1,17 @@ +from __future__ import annotations + +from sqlalchemy import ForeignKey + +from spiffworkflow_backend.models.db import db +from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel +from spiffworkflow_backend.models.user import UserModel + + +class ActiveUserModel(SpiffworkflowBaseDBModel): + __tablename__ = "active_user" + __table_args__ = (db.UniqueConstraint("user_id", "last_visited_identifier", name="user_last_visited_unique"),) + + id: int = db.Column(db.Integer, primary_key=True) + user_id: int = db.Column(ForeignKey(UserModel.id), nullable=False, index=True) # type: ignore + last_visited_identifier: str = db.Column(db.String(255), nullable=False, index=True) + last_seen_in_seconds: int = db.Column(db.Integer, nullable=False, index=True) diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/routes/active_users_controller.py b/spiffworkflow-backend/src/spiffworkflow_backend/routes/active_users_controller.py new file mode 100644 index 000000000..73f881026 --- /dev/null +++ b/spiffworkflow-backend/src/spiffworkflow_backend/routes/active_users_controller.py @@ -0,0 +1,61 @@ +import json +import time +from typing import Generator + +import flask.wrappers +from flask import current_app +from flask import g +from flask import stream_with_context +from flask.wrappers import Response + +from spiffworkflow_backend.models.active_user import ActiveUserModel +from spiffworkflow_backend.models.db import db +from spiffworkflow_backend.models.user import UserModel + + +def active_user_updates(last_visited_identifier: str) -> Response: + active_user = ActiveUserModel.query.filter_by( + user_id=g.user.id, last_visited_identifier=last_visited_identifier + ).first() + if active_user is None: + active_user = ActiveUserModel( + user_id=g.user.id, last_visited_identifier=last_visited_identifier, last_seen_in_seconds=round(time.time()) + ) + db.session.add(active_user) + db.session.commit() + + return Response( + stream_with_context(_active_user_updates(last_visited_identifier, active_user=active_user)), + mimetype="text/event-stream", + headers={"X-Accel-Buffering": "no"}, + ) + + +def _active_user_updates(last_visited_identifier: str, active_user: ActiveUserModel) -> Generator[str, None, None]: + while True: + active_user.last_seen_in_seconds = round(time.time()) + db.session.add(active_user) + db.session.commit() + + cutoff_time_in_seconds = time.time() - 15 + active_users = ( + UserModel.query.join(ActiveUserModel) + .filter(ActiveUserModel.last_visited_identifier == last_visited_identifier) + .filter(ActiveUserModel.last_seen_in_seconds > cutoff_time_in_seconds) + .filter(UserModel.id != g.user.id) + .all() + ) + yield f"data: {current_app.json.dumps(active_users)} \n\n" + + time.sleep(5) + + +def active_user_unregister(last_visited_identifier: str) -> flask.wrappers.Response: + active_user = ActiveUserModel.query.filter_by( + user_id=g.user.id, last_visited_identifier=last_visited_identifier + ).first() + if active_user is not None: + db.session.delete(active_user) + db.session.commit() + + return Response(json.dumps({"ok": True}), status=200, mimetype="application/json") diff --git a/spiffworkflow-backend/src/spiffworkflow_backend/services/logging_service.py b/spiffworkflow-backend/src/spiffworkflow_backend/services/logging_service.py index d9732b0bd..3a5ee845b 100644 --- a/spiffworkflow-backend/src/spiffworkflow_backend/services/logging_service.py +++ b/spiffworkflow-backend/src/spiffworkflow_backend/services/logging_service.py @@ -130,6 +130,7 @@ def setup_logger(app: Flask) -> None: # these loggers have been deemed too verbose to be useful garbage_loggers_to_exclude = ["connexion", "flask_cors.extension"] + loggers_to_exclude_from_debug = ["sqlalchemy"] # make all loggers act the same for name in logging.root.manager.loggerDict: @@ -149,8 +150,17 @@ def setup_logger(app: Flask) -> None: for garbage_logger in garbage_loggers_to_exclude: if name.startswith(garbage_logger): exclude_logger_name_from_logging = True + + exclude_logger_name_from_debug = False + for logger_to_exclude_from_debug in loggers_to_exclude_from_debug: + if name.startswith(logger_to_exclude_from_debug): + exclude_logger_name_from_debug = True + if exclude_logger_name_from_debug: + the_logger.setLevel("INFO") + if not exclude_logger_name_from_logging: the_logger.addHandler(logging.StreamHandler(sys.stdout)) + for the_handler in the_logger.handlers: the_handler.setFormatter(log_formatter) the_handler.setLevel(log_level) diff --git a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py index add3d7b89..9a124f6d3 100644 --- a/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py +++ b/spiffworkflow-backend/tests/spiffworkflow_backend/integration/test_process_api.py @@ -3139,6 +3139,8 @@ class TestProcessApi(BaseTest): "filterable": False, }, {"Header": "Status", "accessor": "status", "filterable": False}, + {"Header": "Task", "accessor": "task_title", "filterable": False}, + {"Header": "Waiting For", "accessor": "waiting_for", "filterable": False}, {"Header": "awesome_var", "accessor": "awesome_var", "filterable": True}, {"Header": "invoice_number", "accessor": "invoice_number", "filterable": True}, {"Header": "key1", "accessor": "key1", "filterable": True}, @@ -3155,6 +3157,8 @@ class TestProcessApi(BaseTest): "end_in_seconds", "process_initiator_username", "status", + "task_title", + "waiting_for", ] assert accessors == stock_columns + ["awesome_var", "invoice_number", "key1", "key2", "key3"] diff --git a/spiffworkflow-frontend/src/components/ReactDiagramEditor.tsx b/spiffworkflow-frontend/src/components/ReactDiagramEditor.tsx index cddb7211d..1855f955e 100644 --- a/spiffworkflow-frontend/src/components/ReactDiagramEditor.tsx +++ b/spiffworkflow-frontend/src/components/ReactDiagramEditor.tsx @@ -88,6 +88,7 @@ type OwnProps = { onElementsChanged?: (..._args: any[]) => any; url?: string; callers?: ProcessModelCaller[]; + activeUserElement?: React.ReactElement; }; // https://codesandbox.io/s/quizzical-lake-szfyo?file=/src/App.js was a handy reference @@ -114,6 +115,7 @@ export default function ReactDiagramEditor({ onElementsChanged, url, callers, + activeUserElement, }: OwnProps) { const [diagramXMLString, setDiagramXMLString] = useState(''); const [diagramModelerState, setDiagramModelerState] = useState(null); @@ -672,6 +674,7 @@ export default function ReactDiagramEditor({ )} {getReferencesButton()} + {activeUserElement || null} ); } @@ -679,9 +682,9 @@ export default function ReactDiagramEditor({ }; return ( -
+ <> {userActionOptions()} {showReferences()} -
+ ); } diff --git a/spiffworkflow-frontend/src/index.css b/spiffworkflow-frontend/src/index.css index db36f1cfb..7f8157879 100644 --- a/spiffworkflow-frontend/src/index.css +++ b/spiffworkflow-frontend/src/index.css @@ -455,3 +455,103 @@ svg.notification-icon { .float-right { float: right; } + +.user-list { + display: flex; + align-items: center; + margin-left: 8px; +} + +.user-circle { + display: flex; + justify-content: center; + align-items: center; + width: 30px; + height: 30px; + border-radius: 50%; + color: #fff; + font-weight: bold; + margin-right: 10px; +} + +.user-circle.color-1 { + background-color: #8e8e8e; +} + +.user-circle.color-2 { + background-color: #a57c63; +} + +.user-circle.color-3 { + background-color: #8f7c7c; +} + +.user-circle.color-4 { + background-color: #9a927f; +} + +.user-circle.color-5 { + background-color: #5e5d5d; +} + +.user-circle.color-6 { + background-color: #676767; +} + +.user-circle.color-7 { + background-color: #6d7d6d; +} + +.user-circle.color-8 { + background-color: #7a7171; +} + +.user-circle.color-9 { + background-color: #837d63; +} + +.user-circle.color-10 { + background-color: #686a70; +} + +/* Randomize color assignment */ +.user-circle:nth-child(1) { + background-color: #8e8e8e; +} + +.user-circle:nth-child(2) { + background-color: #a57c63; +} + +.user-circle:nth-child(3) { + background-color: #8f7c7c; +} + +.user-circle:nth-child(4) { + background-color: #9a927f; +} + +.user-circle:nth-child(5) { + background-color: #5e5d5d; +} + +.user-circle:nth-child(6) { + background-color: #676767; +} + +.user-circle:nth-child(7) { + background-color: #6d7d6d; +} + +.user-circle:nth-child(8) { + background-color: #7a7171; +} + +.user-circle:nth-child(9) { + background-color: #837d63; +} + +/* Default to first color if more than 10 users */ +.user-circle:nth-child(n+11) { + background-color: #8e8e8e; +} diff --git a/spiffworkflow-frontend/src/routes/ProcessModelEditDiagram.tsx b/spiffworkflow-frontend/src/routes/ProcessModelEditDiagram.tsx index 6cd81fb7d..9ea22a5ce 100644 --- a/spiffworkflow-frontend/src/routes/ProcessModelEditDiagram.tsx +++ b/spiffworkflow-frontend/src/routes/ProcessModelEditDiagram.tsx @@ -25,17 +25,24 @@ import Col from 'react-bootstrap/Col'; import Editor, { DiffEditor } from '@monaco-editor/react'; import MDEditor from '@uiw/react-md-editor'; +import { fetchEventSource } from '@microsoft/fetch-event-source'; +import { BACKEND_BASE_URL } from '../config'; +import HttpService, { getBasicHeaders } from '../services/HttpService'; import ReactDiagramEditor from '../components/ReactDiagramEditor'; import ProcessBreadcrumb from '../components/ProcessBreadcrumb'; -import HttpService from '../services/HttpService'; import useAPIError from '../hooks/UseApiError'; -import { makeid, modifyProcessIdentifierForPathParam } from '../helpers'; +import { + makeid, + modifyProcessIdentifierForPathParam, + encodeBase64, +} from '../helpers'; import { CarbonComboBoxProcessSelection, ProcessFile, ProcessModel, ProcessModelCaller, ProcessReference, + User, } from '../interfaces'; import ProcessSearch from '../components/ProcessSearch'; import { Notification } from '../components/Notification'; @@ -66,6 +73,7 @@ export default function ProcessModelEditDiagram() { useState(false); const [processModelFileInvalidText, setProcessModelFileInvalidText] = useState(''); + const [activeUsers, setActiveUsers] = useState([]); const handleShowMarkdownEditor = () => setShowMarkdownEditor(true); @@ -124,6 +132,7 @@ export default function ProcessModelEditDiagram() { usePrompt('Changes you made may not be saved.', diagramHasChanges); + const lastVisitedIdentifier = encodeBase64(window.location.pathname); useEffect(() => { // Grab all available process models in case we need to search for them. // Taken from the Process Group List @@ -139,17 +148,39 @@ export default function ProcessModelEditDiagram() { path: `/processes`, successCallback: processResults, }); - }, []); - useEffect(() => { - const processResult = (result: ProcessModel) => { - setProcessModel(result); + const unregisterUser = () => { + HttpService.makeCallToBackend({ + path: `/active-users/unregister/${lastVisitedIdentifier}`, + successCallback: setActiveUsers, + }); }; - HttpService.makeCallToBackend({ - path: `/${processModelPath}?include_file_references=true`, - successCallback: processResult, - }); - }, [processModelPath]); + fetchEventSource( + `${BACKEND_BASE_URL}/active-users/updates/${lastVisitedIdentifier}`, + { + headers: getBasicHeaders(), + onmessage(ev) { + const retValue = JSON.parse(ev.data); + if ('error_code' in retValue) { + addError(retValue); + } else { + setActiveUsers(retValue); + } + }, + onclose() { + unregisterUser(); + }, + onerror(err: any) { + throw err; + }, + } + ); + + // FIXME: this is not getting called when navigating away from this page. + // we do not know why yet. + return unregisterUser; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); // it is critical to only run this once. useEffect(() => { const fileResult = (result: any) => { @@ -157,6 +188,11 @@ export default function ProcessModelEditDiagram() { setBpmnXmlForDiagramRendering(result.file_contents); }; + HttpService.makeCallToBackend({ + path: `/${processModelPath}?include_file_references=true`, + successCallback: setProcessModel, + }); + if (params.file_name) { HttpService.makeCallToBackend({ path: `/${processModelPath}/files/${params.file_name}`, @@ -930,6 +966,20 @@ export default function ProcessModelEditDiagram() { return searchParams.get('file_type') === 'dmn' || fileName.endsWith('.dmn'); }; + const activeUserElement = () => { + const au = activeUsers.map((activeUser: User) => { + return ( +
+ {activeUser.username.charAt(0).toUpperCase()} +
+ ); + }); + return
{au}
; + }; + const appropriateEditor = () => { if (isDmn()) { return ( @@ -974,6 +1024,7 @@ export default function ProcessModelEditDiagram() { onSearchProcessModels={onSearchProcessModels} onElementsChanged={onElementsChanged} callers={callers} + activeUserElement={activeUserElement()} /> ); };