added ability to display users showing on the process model edit diagram page w/ burnettk
This commit is contained in:
parent
34d78cff17
commit
a219d8efd4
|
@ -0,0 +1,44 @@
|
|||
"""empty message
|
||||
|
||||
Revision ID: a41f08815751
|
||||
Revises: 68adb1d504e1
|
||||
Create Date: 2023-05-03 16:51:35.252279
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'a41f08815751'
|
||||
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=True),
|
||||
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_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'))
|
||||
|
||||
op.drop_table('active_user')
|
||||
# ### end Alembic commands ###
|
|
@ -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
|
||||
|
@ -1701,6 +1745,7 @@ paths:
|
|||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ServiceTask"
|
||||
|
||||
/tasks/{process_instance_id}:
|
||||
parameters:
|
||||
- name: process_instance_id
|
||||
|
@ -1722,7 +1767,6 @@ paths:
|
|||
schema:
|
||||
$ref: "#/components/schemas/Task"
|
||||
|
||||
|
||||
/tasks/{process_instance_id}/{task_guid}:
|
||||
parameters:
|
||||
- name: task_guid
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
from __future__ import annotations
|
||||
from spiffworkflow_backend.models.user import UserModel
|
||||
from sqlalchemy import ForeignKey
|
||||
|
||||
from spiffworkflow_backend.models.db import db
|
||||
from spiffworkflow_backend.models.db import SpiffworkflowBaseDBModel
|
||||
|
||||
|
||||
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)
|
|
@ -0,0 +1,66 @@
|
|||
import json
|
||||
import os
|
||||
import time
|
||||
from spiffworkflow_backend.models.user import UserModel
|
||||
from spiffworkflow_backend.models.db import db
|
||||
from typing import Generator
|
||||
from flask import stream_with_context
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import Optional
|
||||
from typing import Union
|
||||
|
||||
import connexion # type: ignore
|
||||
import flask.wrappers
|
||||
from flask import current_app
|
||||
from flask import g
|
||||
from flask import jsonify
|
||||
from flask import make_response
|
||||
from flask.wrappers import Response
|
||||
from spiffworkflow_backend.models.active_user import ActiveUserModel
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
|
||||
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)
|
||||
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()
|
||||
|
||||
time.sleep(1)
|
||||
cutoff_time_in_seconds = time.time() - 7
|
||||
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"
|
||||
|
||||
|
||||
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")
|
|
@ -9,6 +9,10 @@ def status() -> Response:
|
|||
"""Status."""
|
||||
ProcessInstanceModel.query.filter().first()
|
||||
return make_response({"ok": True}, 200)
|
||||
def status2() -> Response:
|
||||
"""Status."""
|
||||
ProcessInstanceModel.query.filter().first()
|
||||
return make_response({"ok": True}, 200)
|
||||
|
||||
|
||||
def test_raise_error() -> Response:
|
||||
|
|
|
@ -455,3 +455,102 @@ svg.notification-icon {
|
|||
.float-right {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.user-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
|
|
@ -25,9 +25,11 @@ 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 {
|
||||
|
@ -36,6 +38,7 @@ import {
|
|||
ProcessModel,
|
||||
ProcessModelCaller,
|
||||
ProcessReference,
|
||||
User,
|
||||
} from '../interfaces';
|
||||
import ProcessSearch from '../components/ProcessSearch';
|
||||
import { Notification } from '../components/Notification';
|
||||
|
@ -66,6 +69,7 @@ export default function ProcessModelEditDiagram() {
|
|||
useState<boolean>(false);
|
||||
const [processModelFileInvalidText, setProcessModelFileInvalidText] =
|
||||
useState<string>('');
|
||||
const [activeUsers, setActiveUsers] = useState<User[]>([]);
|
||||
|
||||
const handleShowMarkdownEditor = () => setShowMarkdownEditor(true);
|
||||
|
||||
|
@ -125,21 +129,49 @@ export default function ProcessModelEditDiagram() {
|
|||
usePrompt('Changes you made may not be saved.', diagramHasChanges);
|
||||
|
||||
useEffect(() => {
|
||||
// Grab all available process models in case we need to search for them.
|
||||
// Taken from the Process Group List
|
||||
const processResults = (result: any) => {
|
||||
const selectionArray = result.map((item: any) => {
|
||||
const label = `${item.display_name} (${item.identifier})`;
|
||||
Object.assign(item, { label });
|
||||
return item;
|
||||
});
|
||||
setProcesses(selectionArray);
|
||||
};
|
||||
HttpService.makeCallToBackend({
|
||||
path: `/processes`,
|
||||
successCallback: processResults,
|
||||
});
|
||||
}, []);
|
||||
const lastVisitedIdentifier = 'HEY';
|
||||
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() {
|
||||
HttpService.makeCallToBackend({
|
||||
path: `/active-users/unregister/${lastVisitedIdentifier}`,
|
||||
successCallback: setActiveUsers,
|
||||
});
|
||||
},
|
||||
onerror(err: any) {
|
||||
throw err;
|
||||
},
|
||||
}
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []); // it is critical to only run this once.
|
||||
|
||||
// useEffect(() => {
|
||||
// // Grab all available process models in case we need to search for them.
|
||||
// // Taken from the Process Group List
|
||||
// const processResults = (result: any) => {
|
||||
// const selectionArray = result.map((item: any) => {
|
||||
// const label = `${item.display_name} (${item.identifier})`;
|
||||
// Object.assign(item, { label });
|
||||
// return item;
|
||||
// });
|
||||
// setProcesses(selectionArray);
|
||||
// };
|
||||
// HttpService.makeCallToBackend({
|
||||
// path: `/processes`,
|
||||
// successCallback: processResults,
|
||||
// });
|
||||
// }, []);
|
||||
|
||||
useEffect(() => {
|
||||
const processResult = (result: ProcessModel) => {
|
||||
|
@ -989,6 +1021,17 @@ export default function ProcessModelEditDiagram() {
|
|||
return null;
|
||||
};
|
||||
|
||||
const activeUserElement = () => {
|
||||
const au = activeUsers.map((activeUser: User) => {
|
||||
return (
|
||||
<div title={activeUser.username} className="user-circle">
|
||||
{activeUser.username.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
return <div className="user-list">{au}</div>;
|
||||
};
|
||||
|
||||
// if a file name is not given then this is a new model and the ReactDiagramEditor component will handle it
|
||||
if ((bpmnXmlForDiagramRendering || !params.file_name) && processModel) {
|
||||
const processModelFileName = processModelFile ? processModelFile.name : '';
|
||||
|
@ -1009,6 +1052,7 @@ export default function ProcessModelEditDiagram() {
|
|||
Process Model File{processModelFile ? ': ' : ''}
|
||||
{processModelFileName}
|
||||
</h1>
|
||||
{activeUserElement()}
|
||||
{saveFileMessage()}
|
||||
{appropriateEditor()}
|
||||
{newFileNameBox()}
|
||||
|
|
Loading…
Reference in New Issue