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 ( -