From 965d7bdc34d039a25c279e2acb41b54d61ab1696 Mon Sep 17 00:00:00 2001 From: Lukasz Sojka Date: Thu, 31 Oct 2024 11:15:43 +0100 Subject: [PATCH] improvement(views): Highlights View widget Introducing Highlights View Widget that should aid Views with important messages, action items and allow commenting upon them. Originally, intended for performance view dashboard, where important messages and action items are discussed over weekly performance regression tests. With this widget, users may: * create messages (highlights) for given view * create action items and assign Assignee, mark item as done upon completion * comment each highlight/action item * archive hightlights/action items to hide it from the view * unarchive it to bring it back More info in ref issue. refs: https://github.com/scylladb/argus/issues/485 --- .../controller/views_widgets/__init__.py | 0 .../controller/views_widgets/highlights.py | 130 +++++ argus/backend/models/view_widgets.py | 22 + argus/backend/models/web.py | 3 + .../backend/service/views_widgets/__init__.py | 0 .../service/views_widgets/highlights.py | 281 ++++++++++ argus/backend/tests/conftest.py | 26 +- argus/backend/tests/view_widgets/__init__.py | 0 .../tests/view_widgets/test_highlights_api.py | 482 ++++++++++++++++++ argus_backend.py | 2 + frontend/Common/AssigneeSelector.svelte | 53 ++ frontend/Common/ViewTypes.js | 11 +- frontend/Discussion/UserProfile.svelte | 13 - frontend/Profile/UserSelection.svelte | 22 + frontend/ReleasePlanner/ReleasePlan.svelte | 12 - .../Widgets/ViewHighlights/ActionItem.svelte | 103 ++++ .../Widgets/ViewHighlights/Comment.svelte | 52 ++ .../ViewHighlights/HighlightItem.svelte | 87 ++++ .../ViewHighlights/ViewHighlights.svelte | 410 +++++++++++++++ frontend/argus.css | 13 +- poetry.lock | 68 ++- pyproject.toml | 5 + 22 files changed, 1762 insertions(+), 33 deletions(-) create mode 100644 argus/backend/controller/views_widgets/__init__.py create mode 100644 argus/backend/controller/views_widgets/highlights.py create mode 100644 argus/backend/models/view_widgets.py create mode 100644 argus/backend/service/views_widgets/__init__.py create mode 100644 argus/backend/service/views_widgets/highlights.py create mode 100644 argus/backend/tests/view_widgets/__init__.py create mode 100644 argus/backend/tests/view_widgets/test_highlights_api.py create mode 100644 frontend/Common/AssigneeSelector.svelte create mode 100644 frontend/Profile/UserSelection.svelte create mode 100644 frontend/Views/Widgets/ViewHighlights/ActionItem.svelte create mode 100644 frontend/Views/Widgets/ViewHighlights/Comment.svelte create mode 100644 frontend/Views/Widgets/ViewHighlights/HighlightItem.svelte create mode 100644 frontend/Views/Widgets/ViewHighlights/ViewHighlights.svelte diff --git a/argus/backend/controller/views_widgets/__init__.py b/argus/backend/controller/views_widgets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/argus/backend/controller/views_widgets/highlights.py b/argus/backend/controller/views_widgets/highlights.py new file mode 100644 index 00000000..0f50cf2f --- /dev/null +++ b/argus/backend/controller/views_widgets/highlights.py @@ -0,0 +1,130 @@ +from dataclasses import dataclass, asdict +from datetime import datetime, UTC + +from flask import Blueprint, request, g + +from argus.backend.service.user import api_login_required +from argus.backend.service.views_widgets.highlights import ( + HighlightCreate, + HighlightsService, + HighlightArchive, + HighlightUpdate, + HighlightSetAssignee, + HighlightSetCompleted, + CommentUpdate, + CommentDelete, + CommentCreate, +) +from argus.backend.util.common import get_payload + +bp = Blueprint("view_widgets", __name__, url_prefix="/widgets") + + +@bp.route("/highlights/create", methods=["POST"]) +@api_login_required +def create_highlight(): + creator_id = g.user.id + payload = HighlightCreate(**get_payload(request)) + service = HighlightsService() + highlight = service.create(creator_id, payload) + return {"status": "ok", "response": asdict(highlight)} + + +@bp.route("/highlights", methods=["GET"]) +@api_login_required +def get_highlights(): + view_id = request.args.get("view_id") + service = HighlightsService() + highlights, action_items = service.get_highlights(view_id) + return { + "status": "ok", + "response": { + "highlights": [asdict(h) for h in highlights], + "action_items": [asdict(a) for a in action_items], + }, + } + + +@bp.route("/highlights/archive", methods=["POST"]) +@api_login_required +def archive_highlight(): + payload = HighlightArchive(**get_payload(request)) + service = HighlightsService() + service.archive_highlight(payload.view_id, payload.created_at, datetime.now(UTC)) + return {"status": "ok"} + + +@bp.route("/highlights/unarchive", methods=["POST"]) +@api_login_required +def unarchive_highlight(): + payload = HighlightArchive(**get_payload(request)) + service = HighlightsService() + service.archive_highlight(payload.view_id, payload.created_at, datetime.fromtimestamp(0, tz=UTC)) + return {"status": "ok"} + + +@bp.route("/highlights/update", methods=["POST"]) +@api_login_required +def update_highlight(): + payload = HighlightUpdate(**get_payload(request)) + service = HighlightsService() + updated_highlight = service.update_highlight(g.user.id, payload) + return {"status": "ok", "response": asdict(updated_highlight)} + + +@bp.route("/highlights/set_assignee", methods=["POST"]) +@api_login_required +def set_assignee(): + payload = HighlightSetAssignee(**get_payload(request)) + service = HighlightsService() + updated_action_item = service.set_assignee(payload) + return {"status": "ok", "response": asdict(updated_action_item)} + + +@bp.route("/highlights/set_completed", methods=["POST"]) +@api_login_required +def set_completed(): + payload = HighlightSetCompleted(**get_payload(request)) + service = HighlightsService() + updated_action_item = service.set_completed(payload) + return {"status": "ok", "response": asdict(updated_action_item)} + + +@bp.route("/highlights/comments/create", methods=["POST"]) +@api_login_required +def create_comment(): + creator_id = g.user.id + payload = CommentCreate(**get_payload(request)) + service = HighlightsService() + comment = service.create_comment(creator_id, payload) + return {"status": "ok", "response": asdict(comment)} + + +@bp.route("/highlights/comments/update", methods=["POST"]) +@api_login_required +def update_comment(): + user_id = g.user.id + payload = CommentUpdate(**get_payload(request)) + service = HighlightsService() + updated_comment = service.update_comment(user_id, payload) + return {"status": "ok", "response": asdict(updated_comment)} + + +@bp.route("/highlights/comments/delete", methods=["POST"]) +@api_login_required +def delete_comment(): + user_id = g.user.id + payload = CommentDelete(**get_payload(request)) + service = HighlightsService() + service.delete_comment(user_id, payload) + return {"status": "ok"} + + +@bp.route("/highlights/comments", methods=["GET"]) +@api_login_required +def get_comments(): + view_id = request.args.get("view_id") + highlight_created_at = float(request.args.get("created_at")) + service = HighlightsService() + comments = service.get_comments(view_id, highlight_created_at) + return {"status": "ok", "response": [asdict(c) for c in comments]} diff --git a/argus/backend/models/view_widgets.py b/argus/backend/models/view_widgets.py new file mode 100644 index 00000000..325037fb --- /dev/null +++ b/argus/backend/models/view_widgets.py @@ -0,0 +1,22 @@ +from datetime import datetime, UTC + +from cassandra.cqlengine import columns +from cassandra.cqlengine.models import Model + + +class WidgetHighlights(Model): + view_id = columns.UUID(partition_key=True, required=True) + created_at = columns.DateTime(primary_key=True, clustering_order="DESC") + archived_at = columns.DateTime(default=datetime.fromtimestamp(0, tz=UTC)) + creator_id = columns.UUID() + assignee_id = columns.UUID() + content = columns.Text() + completed = columns.Boolean(default=lambda: None) # None means it's highlight, not an action item + comments_count = columns.TinyInt() + +class WidgetComment(Model): + view_id = columns.UUID(partition_key=True, required=True) + highlight_at = columns.DateTime(partition_key=True, required=True) # reference to WidgetHighlights.created_at + created_at = columns.DateTime(primary_key=True) + creator_id = columns.UUID() + content = columns.Text() diff --git a/argus/backend/models/web.py b/argus/backend/models/web.py index 48bdd3ad..e003df11 100644 --- a/argus/backend/models/web.py +++ b/argus/backend/models/web.py @@ -8,6 +8,7 @@ from argus.backend.models.plan import ArgusReleasePlan from argus.backend.models.result import ArgusGenericResultMetadata, ArgusGenericResultData, ArgusBestResultData +from argus.backend.models.view_widgets import WidgetHighlights, WidgetComment def uuid_now(): @@ -385,6 +386,8 @@ class WebFileStorage(Model): ArgusGenericResultData, ArgusBestResultData, ArgusReleasePlan, + WidgetHighlights, + WidgetComment, ] USED_TYPES: list[UserType] = [ diff --git a/argus/backend/service/views_widgets/__init__.py b/argus/backend/service/views_widgets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/argus/backend/service/views_widgets/highlights.py b/argus/backend/service/views_widgets/highlights.py new file mode 100644 index 00000000..4224d6cf --- /dev/null +++ b/argus/backend/service/views_widgets/highlights.py @@ -0,0 +1,281 @@ +from dataclasses import dataclass +from datetime import datetime, UTC +from uuid import UUID + +from flask import abort + +from argus.backend.db import ScyllaCluster +from argus.backend.models.view_widgets import WidgetHighlights, WidgetComment + + +@dataclass(frozen=True) +class Highlight: + view_id: UUID + created_at: datetime + creator_id: UUID + content: str + archived_at: datetime + comments_count: int + + @classmethod + def from_db_model(cls, model: WidgetHighlights): + created_at = model.created_at.replace(tzinfo=UTC).timestamp() + archived_at = model.archived_at.replace(tzinfo=UTC).timestamp() if model.archived_at else None + return cls( + view_id=model.view_id, + created_at=created_at, + creator_id=model.creator_id, + content=model.content, + archived_at=archived_at, + comments_count=model.comments_count, + ) + + +@dataclass(frozen=True) +class ActionItem(Highlight): + assignee_id: UUID | None + completed: bool + + @classmethod + def from_db_model(cls, model: WidgetHighlights): + created_at = model.created_at.replace(tzinfo=UTC).timestamp() + archived_at = model.archived_at.replace(tzinfo=UTC).timestamp() if model.archived_at else None + return cls( + view_id=model.view_id, + created_at=created_at, + creator_id=model.creator_id, + content=model.content, + archived_at=archived_at, + comments_count=model.comments_count, + assignee_id=model.assignee_id, + completed=model.completed, + ) + + +@dataclass +class CommentCreate: + view_id: str + highlight_created_at: float + content: str + + +@dataclass +class CommentUpdate: + view_id: str + highlight_created_at: float + created_at: float + content: str + + +@dataclass +class CommentDelete: + view_id: str + highlight_created_at: float + created_at: float + + +@dataclass(frozen=True) +class Comment: + view_id: UUID + highlight_created_at: datetime + created_at: datetime + creator_id: UUID + content: str + + @classmethod + def from_db_model(cls, model: WidgetComment): + highlight_created_at = model.highlight_at.replace(tzinfo=UTC).timestamp() + created_at = model.created_at.replace(tzinfo=UTC).timestamp() + return cls( + view_id=model.view_id, + highlight_created_at=highlight_created_at, + created_at=created_at, + creator_id=model.creator_id, + content=model.content, + ) + + +@dataclass +class HighlightCreate: + view_id: str + content: str + is_task: bool + + +@dataclass +class HighlightArchive: + view_id: str + created_at: float + + +@dataclass +class HighlightUpdate: + view_id: str + created_at: float + content: str + + +@dataclass +class HighlightSetAssignee: + view_id: str + created_at: float + assignee_id: str | None = None + + +@dataclass +class HighlightSetCompleted: + view_id: str + created_at: float + completed: bool + + +class HighlightsService: + + def __init__(self) -> None: + self.cluster = ScyllaCluster.get() + + def create( + self, + creator: UUID, + payload: HighlightCreate, + ) -> Highlight | ActionItem: + created_at = datetime.now(UTC) + highlight = WidgetHighlights( + view_id=UUID(payload.view_id), + created_at=created_at, + creator_id=creator, + content=payload.content, + completed=None if not payload.is_task else False, + archived=datetime.fromtimestamp(0, tz=UTC), + comments_count=0, + ) + highlight.save() + if payload.is_task: + return ActionItem.from_db_model(highlight) + return Highlight.from_db_model(highlight) + + def archive_highlight(self, view_id: str, created_at: float, archived_at: datetime): + entry = WidgetHighlights.objects( + view_id=UUID(view_id), created_at=datetime.fromtimestamp(created_at, tz=UTC) + ).first() + if entry: + entry.archived_at = archived_at + entry.save() + + def unarchive_highlight(self, view_id: str, created_at: float): + entry = WidgetHighlights.objects( + view_id=UUID(view_id), + created_at=datetime.fromtimestamp(created_at, tz=UTC) + ).first() + if entry: + entry.archived_at = datetime.fromtimestamp(0, tz=UTC) + entry.save() + + def update_highlight(self, user_id: UUID, payload: HighlightUpdate) -> Highlight | ActionItem: + entry = WidgetHighlights.objects( + view_id=UUID(payload.view_id), + created_at=datetime.fromtimestamp(payload.created_at, tz=UTC) + ).first() + if not entry: + abort(404, description="Highlight not found") + if entry.creator_id != user_id: + abort(403, description="Not authorized to update highlight") + entry.content = payload.content + entry.save() + if entry.completed is None: + return Highlight.from_db_model(entry) + else: + return ActionItem.from_db_model(entry) + + def set_assignee(self, payload: HighlightSetAssignee) -> ActionItem: + entry = WidgetHighlights.objects( + view_id=UUID(payload.view_id), + created_at=datetime.fromtimestamp(payload.created_at, tz=UTC) + ).first() + if not entry or entry.completed is None: + abort(404, description="ActionItem not found") + if payload.assignee_id is None: + entry.assignee_id = None + else: + entry.assignee_id = UUID(payload.assignee_id) + entry.save() + return ActionItem.from_db_model(entry) + + def set_completed(self, payload: HighlightSetCompleted) -> ActionItem: + entry = WidgetHighlights.objects( + view_id=UUID(payload.view_id), + created_at=datetime.fromtimestamp(payload.created_at, tz=UTC) + ).first() + if not entry or entry.completed is None: + abort(404, description="ActionItem not found") + entry.completed = payload.completed + entry.save() + return ActionItem.from_db_model(entry) + + def get_highlights(self, view_id: str) -> tuple[list[Highlight], list[ActionItem]]: + entries = WidgetHighlights.objects(view_id=UUID(view_id)) + highlights = [Highlight.from_db_model(entry) for entry in entries if entry.completed is None] + action_items = [ActionItem.from_db_model(entry) for entry in entries if entry.completed is not None] + return highlights, action_items + + def create_comment(self, creator_id: UUID, payload: CommentCreate) -> Comment: + view_id = UUID(payload.view_id) + highlight_created_at = datetime.fromtimestamp(payload.highlight_created_at, tz=UTC) + highlight = WidgetHighlights.objects(view_id=view_id, created_at=highlight_created_at).first() + if not highlight: + abort(404, description="Highlight not found") + created_at = datetime.now(UTC) + comment = WidgetComment( + view_id=view_id, + highlight_at=highlight_created_at, + created_at=created_at, + creator_id=creator_id, + content=payload.content, + ) + comment.save() + highlight.comments_count += 1 + highlight.save() + return Comment.from_db_model(comment) + + def update_comment(self, user_id: UUID, payload: CommentUpdate) -> Comment: + view_id = UUID(payload.view_id) + highlight_created_at = datetime.fromtimestamp(payload.highlight_created_at, tz=UTC) + created_at = datetime.fromtimestamp(payload.created_at, tz=UTC) + comment = WidgetComment.objects( + view_id=view_id, + highlight_at=highlight_created_at, + created_at=created_at, + ).first() + if not comment: + abort(404, description="Comment not found") + if comment.creator_id != user_id: + abort(403, description="Not authorized to update comment") + comment.content = payload.content + comment.save() + return Comment.from_db_model(comment) + + def delete_comment(self, user_id: UUID, payload: CommentDelete): + view_id = UUID(payload.view_id) + highlight_created_at = datetime.fromtimestamp(payload.highlight_created_at, tz=UTC) + created_at = datetime.fromtimestamp(payload.created_at, tz=UTC) + comment = WidgetComment.objects( + view_id=view_id, + highlight_at=highlight_created_at, + created_at=created_at, + ).first() + if not comment: + abort(404, description="Comment not found") + if comment.creator_id != user_id: + abort(403, description="Not authorized to delete comment") + comment.delete() + highlight = WidgetHighlights.objects(view_id=view_id, created_at=highlight_created_at).first() + if not highlight: + abort(404, description="Highlight not found") + highlight.comments_count -= 1 + highlight.save() + + def get_comments(self, view_id: str, highlight_created_at: float) -> list[Comment]: + view_id = UUID(view_id) + highlight_created_at = datetime.fromtimestamp(highlight_created_at, tz=UTC) + comments = WidgetComment.objects(view_id=view_id, highlight_at=highlight_created_at) + return [Comment.from_db_model(c) for c in comments] diff --git a/argus/backend/tests/conftest.py b/argus/backend/tests/conftest.py index 44972685..b468cab0 100644 --- a/argus/backend/tests/conftest.py +++ b/argus/backend/tests/conftest.py @@ -1,9 +1,11 @@ import os import time import uuid +from unittest.mock import patch from cassandra.auth import PlainTextAuthProvider from docker import DockerClient +from flask import g from argus.backend.util.config import Config @@ -12,13 +14,14 @@ from docker.errors import NotFound from argus.backend.cli import sync_models from argus.backend.db import ScyllaCluster -from argus.backend.models.web import ArgusTest, ArgusGroup, ArgusRelease +from argus.backend.models.web import ArgusTest, ArgusGroup, ArgusRelease, User, UserRoles from argus.backend.plugins.sct.testrun import SCTTestRunSubmissionRequest from argus.backend.service.client_service import ClientService from argus.backend.service.release_manager import ReleaseManagerService from argus.backend.service.results_service import ResultsService import logging from cassandra.cluster import Cluster + logging.getLogger().setLevel(logging.INFO) os.environ['CQLENG_ALLOW_SCHEMA_MANAGEMENT'] = '1' logging.getLogger('cassandra').setLevel(logging.WARNING) @@ -80,7 +83,7 @@ def argus_db(): CREATE KEYSPACE IF NOT EXISTS test_argus WITH replication = {'class': 'SimpleStrategy', 'replication_factor' : 1}; """) - config = {"SCYLLA_KEYSPACE_NAME": "test_argus","SCYLLA_CONTACT_POINTS": [container_ip], + config = {"SCYLLA_KEYSPACE_NAME": "test_argus", "SCYLLA_CONTACT_POINTS": [container_ip], "SCYLLA_USERNAME": "cassandra", "SCYLLA_PASSWORD": "cassandra", "APP_LOG_LEVEL": "INFO", "EMAIL_SENDER": "unit tester", "EMAIL_SENDER_PASS": "pass", "EMAIL_SENDER_USER": "qa", "EMAIL_SERVER": "fake", "EMAIL_SERVER_PORT": 25} @@ -93,13 +96,28 @@ def argus_db(): database.shutdown() +@fixture(scope='session') +def argus_app(): + with patch('argus.backend.service.user.load_logged_in_user') as mock_load: + mock_load.return_value = None # Make the function do nothing so test can override user + from argus_backend import argus_app + yield argus_app + + @fixture(scope='session', autouse=True) -def app_context(argus_db): - from argus_backend import argus_app +def app_context(argus_db, argus_app): with argus_app.app_context(): + g.user = User(id=uuid.uuid4(), username='test_user', full_name='Test User', + email="tester@scylladb.com", + roles=[UserRoles.User, UserRoles.Admin, UserRoles.Manager]) yield +@fixture(scope='session') +def flask_client(argus_app): + return argus_app.test_client() + + @fixture(scope='session') def release_manager_service(argus_db) -> ReleaseManagerService: return ReleaseManagerService() diff --git a/argus/backend/tests/view_widgets/__init__.py b/argus/backend/tests/view_widgets/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/argus/backend/tests/view_widgets/test_highlights_api.py b/argus/backend/tests/view_widgets/test_highlights_api.py new file mode 100644 index 00000000..5b5102ba --- /dev/null +++ b/argus/backend/tests/view_widgets/test_highlights_api.py @@ -0,0 +1,482 @@ +import json +from datetime import datetime, UTC +from uuid import uuid4, UUID + +from flask import g + +from argus.backend.models.view_widgets import WidgetHighlights, WidgetComment + + +def test_create_highlight_should_return_created_highlight(flask_client): + view_id = str(uuid4()) + now = datetime.now(UTC) + response = flask_client.post( + "/widgets/highlights/create", + data=json.dumps({"view_id": view_id, "content": "Highlight content", "is_task": False}), + content_type="application/json", + ) + + assert response.status_code == 200, response.text + assert response.json["status"] == "ok" + assert response.json["response"]["view_id"] == view_id + assert response.json["response"]["content"] == "Highlight content" + assert response.json["response"]["archived_at"] == 0 + assert response.json["response"]["comments_count"] == 0 + assert response.json["response"]["creator_id"] == str(g.user.id) + assert response.json["response"]["created_at"] > now.timestamp() + assert "completed" not in response.json["response"] + assert "assignee_id" not in response.json["response"] + + +def test_create_action_item_should_return_created_action_item(flask_client): + view_id = str(uuid4()) + now = datetime.now(UTC) + response = flask_client.post( + "/widgets/highlights/create", + data=json.dumps({"view_id": view_id, "content": "Action item content", "is_task": True}), + content_type="application/json", + ) + + assert response.status_code == 200, response.text + assert response.json["status"] == "ok" + assert response.json["response"]["view_id"] == view_id + assert response.json["response"]["content"] == "Action item content" + assert response.json["response"]["completed"] is False + assert response.json["response"]["assignee_id"] is None + assert response.json["response"]["archived_at"] == 0 + assert response.json["response"]["comments_count"] == 0 + assert response.json["response"]["creator_id"] == str(g.user.id) + assert response.json["response"]["created_at"] > now.timestamp() + + +def test_get_highlights_should_return_highlights_and_action_items(flask_client): + view_id = str(uuid4()) + creator_id = g.user.id + + highlight_entry = WidgetHighlights( + view_id=UUID(view_id), + created_at=datetime.now(UTC), + creator_id=creator_id, + content="Test highlight", + completed=None, + comments_count=0, + ) + highlight_entry.save() + + action_item_entry = WidgetHighlights( + view_id=UUID(view_id), + created_at=datetime.now(UTC), + creator_id=creator_id, + content="Test action item", + completed=False, + comments_count=0, + ) + action_item_entry.save() + + response = flask_client.get(f"/widgets/highlights?view_id={view_id}") + + assert response.status_code == 200 + assert response.json["status"] == "ok" + highlights = response.json["response"]["highlights"] + action_items = response.json["response"]["action_items"] + + assert len(highlights) == 1 + assert len(action_items) == 1 + assert highlights[0]["content"] == "Test highlight" + assert action_items[0]["content"] == "Test action item" + + +def test_archive_highlight_should_mark_highlight_as_archived(flask_client): + view_id = str(uuid4()) + created_at = datetime.now(UTC) + creator_id = g.user.id + highlight_entry = WidgetHighlights( + view_id=UUID(view_id), + created_at=created_at, + creator_id=creator_id, + content="Test highlight", + completed=None, + comments_count=0, + archived_at=datetime.fromtimestamp(0, tz=UTC), + ) + highlight_entry.save() + + response = flask_client.post( + "/widgets/highlights/archive", + data=json.dumps( + { + "view_id": view_id, + "created_at": created_at.timestamp(), + } + ), + content_type="application/json", + ) + assert response.status_code == 200 + assert response.json["status"] == "ok" + + archived_entry = WidgetHighlights.objects(view_id=UUID(view_id), created_at=created_at).first() + assert archived_entry.archived_at.replace(tzinfo=UTC) > created_at + + +def test_unarchive_highlight_should_unmark_highlight_from_archived(flask_client): + view_id = str(uuid4()) + created_at = datetime.now(UTC) + creator_id = g.user.id + archived_time = datetime.now(UTC) + highlight_entry = WidgetHighlights( + view_id=UUID(view_id), + created_at=created_at, + creator_id=creator_id, + content="Test highlight", + completed=None, + comments_count=0, + archived_at=archived_time, + ) + highlight_entry.save() + + response = flask_client.post( + "/widgets/highlights/unarchive", + data=json.dumps( + { + "view_id": view_id, + "created_at": created_at.timestamp(), + } + ), + content_type="application/json", + ) + assert response.status_code == 200 + assert response.json["status"] == "ok" + + unarchived_entry = WidgetHighlights.objects(view_id=UUID(view_id), created_at=created_at).first() + assert unarchived_entry.archived_at.replace(tzinfo=UTC) == datetime.fromtimestamp(0, tz=UTC) + + +def test_update_highlight_should_update_content_for_creator(flask_client): + view_id = str(uuid4()) + created_at = datetime.now(UTC) + creator_id = g.user.id + original_content = "Original content" + updated_content = "Updated content" + + highlight_entry = WidgetHighlights( + view_id=UUID(view_id), + created_at=created_at, + creator_id=creator_id, + content=original_content, + completed=None, + comments_count=0, + archived_at=datetime.fromtimestamp(0, tz=UTC), + ) + highlight_entry.save() + + response = flask_client.post( + "/widgets/highlights/update", + data=json.dumps({ + "view_id": view_id, + "created_at": created_at.timestamp(), + "content": updated_content, + }), + content_type="application/json", + ) + assert response.status_code == 200 + assert response.json["status"] == "ok" + assert response.json["response"]["content"] == updated_content + + updated_entry = WidgetHighlights.objects(view_id=UUID(view_id), created_at=created_at).first() + assert updated_entry.content == updated_content + + +def test_update_highlight_should_forbid_non_creator(flask_client): + view_id = str(uuid4()) + created_at = datetime.now(UTC) + creator_id = uuid4() # Different from the logged-in user + original_content = "Original content" + malicious_content = "Malicious update" + + highlight_entry = WidgetHighlights( + view_id=UUID(view_id), + created_at=created_at, + creator_id=creator_id, + content=original_content, + completed=None, + comments_count=0, + archived_at=datetime.fromtimestamp(0, tz=UTC), + ) + highlight_entry.save() + + response = flask_client.post( + "/widgets/highlights/update", + data=json.dumps({ + "view_id": view_id, + "created_at": created_at.timestamp(), + "content": malicious_content, + }), + content_type="application/json", + ) + assert response.status_code == 403 + + unchanged_entry = WidgetHighlights.objects(view_id=UUID(view_id), created_at=created_at).first() + assert unchanged_entry.content == original_content + + +def test_set_completed_should_update_completed_status(flask_client): + view_id = str(uuid4()) + created_at = datetime.now(UTC) + action_item_entry = WidgetHighlights( + view_id=UUID(view_id), + created_at=created_at, + creator_id=g.user.id, + content="Test action item", + completed=False, + comments_count=0, + ) + action_item_entry.save() + + # Set completed to True + response = flask_client.post( + "/widgets/highlights/set_completed", + data=json.dumps({ + "view_id": view_id, + "created_at": created_at.timestamp(), + "completed": True + }), + content_type="application/json", + ) + assert response.status_code == 200 + assert response.json["status"] == "ok" + assert response.json["response"]["completed"] is True + + # Set completed to False + response = flask_client.post( + "/widgets/highlights/set_completed", + data=json.dumps({ + "view_id": view_id, + "created_at": created_at.timestamp(), + "completed": False + }), + content_type="application/json", + ) + assert response.status_code == 200 + assert response.json["response"]["completed"] is False + + +def test_set_completed_should_not_work_for_highlight(flask_client): + view_id = str(uuid4()) + created_at = datetime.now(UTC) + highlight_entry = WidgetHighlights( + view_id=UUID(view_id), + created_at=created_at, + creator_id=g.user.id, + content="Test highlight", + completed=None, + comments_count=0, + ) + highlight_entry.save() + + response = flask_client.post( + "/widgets/highlights/set_completed", + data=json.dumps({ + "view_id": view_id, + "created_at": created_at.timestamp(), + "completed": True + }), + content_type="application/json", + ) + assert response.status_code == 404 + + +def test_set_assignee_should_set_assignee_for_action_item(flask_client): + view_id = str(uuid4()) + created_at = datetime.now(UTC) + action_item_entry = WidgetHighlights( + view_id=UUID(view_id), + created_at=created_at, + creator_id=g.user.id, + content="Test action item", + completed=False, + comments_count=0, + ) + action_item_entry.save() + + new_assignee_id = str(uuid4()) + + response = flask_client.post( + "/widgets/highlights/set_assignee", + data=json.dumps({ + "view_id": view_id, + "created_at": created_at.timestamp(), + "assignee_id": new_assignee_id + }), + content_type="application/json", + ) + assert response.status_code == 200 + assert response.json["status"] == "ok" + assert response.json["response"]["assignee_id"] == new_assignee_id + + updated_entry = WidgetHighlights.objects(view_id=UUID(view_id), created_at=created_at).first() + assert str(updated_entry.assignee_id) == new_assignee_id + + +def test_set_assignee_should_not_work_for_highlight(flask_client): + view_id = str(uuid4()) + created_at = datetime.now(UTC) + highlight_entry = WidgetHighlights( + view_id=UUID(view_id), + created_at=created_at, + creator_id=g.user.id, + content="Test highlight", + completed=None, + comments_count=0, + ) + highlight_entry.save() + + new_assignee_id = str(uuid4()) + + response = flask_client.post( + "/widgets/highlights/set_assignee", + data=json.dumps({ + "view_id": view_id, + "created_at": created_at.timestamp(), + "assignee_id": new_assignee_id + }), + content_type="application/json", + ) + assert response.status_code == 404 + + +def test_create_comment_should_increment_comments_count(flask_client): + view_id = str(uuid4()) + highlight_created_at = datetime.now(UTC) + highlight_entry = WidgetHighlights( + view_id=UUID(view_id), + created_at=highlight_created_at, + creator_id=g.user.id, + content="Test highlight", + completed=None, + comments_count=0, + ) + highlight_entry.save() + + response = flask_client.post( + "/widgets/highlights/comments/create", + data=json.dumps({ + "view_id": view_id, + "highlight_created_at": highlight_created_at.timestamp(), + "content": "Test comment", + }), + content_type="application/json", + ) + assert response.status_code == 200 + assert response.json["status"] == "ok" + assert response.json["response"]["content"] == "Test comment" + + updated_highlight = WidgetHighlights.objects(view_id=UUID(view_id), created_at=highlight_created_at).first() + assert updated_highlight.comments_count == 1 + + +def test_delete_comment_should_decrement_comments_count(flask_client): + view_id = str(uuid4()) + highlight_created_at = datetime.now(UTC) + comment_created_at = datetime.now(UTC) + highlight_entry = WidgetHighlights( + view_id=UUID(view_id), + created_at=highlight_created_at, + creator_id=g.user.id, + content="Test highlight", + completed=None, + comments_count=1, + ) + highlight_entry.save() + comment_entry = WidgetComment( + view_id=UUID(view_id), + highlight_at=highlight_created_at, + created_at=comment_created_at, + creator_id=g.user.id, + content="Test comment", + ) + comment_entry.save() + + response = flask_client.post( + "/widgets/highlights/comments/delete", + data=json.dumps({ + "view_id": view_id, + "highlight_created_at": highlight_created_at.timestamp(), + "created_at": comment_created_at.timestamp(), + }), + content_type="application/json", + ) + assert response.status_code == 200 + assert response.json["status"] == "ok" + + updated_highlight = WidgetHighlights.objects(view_id=UUID(view_id), created_at=highlight_created_at).first() + assert updated_highlight.comments_count == 0 + + +def test_update_comment_should_modify_content(flask_client): + view_id = str(uuid4()) + highlight_created_at = datetime.now(UTC) + comment_created_at = datetime.now(UTC) + comment_content = "Original comment" + updated_content = "Updated comment" + + highlight_entry = WidgetHighlights( + view_id=UUID(view_id), + created_at=highlight_created_at, + creator_id=g.user.id, + content="Test highlight", + completed=None, + comments_count=1, + ) + highlight_entry.save() + comment_entry = WidgetComment( + view_id=UUID(view_id), + highlight_at=highlight_created_at, + created_at=comment_created_at, + creator_id=g.user.id, + content=comment_content, + ) + comment_entry.save() + + response = flask_client.post( + "/widgets/highlights/comments/update", + data=json.dumps({ + "view_id": view_id, + "highlight_created_at": highlight_created_at.timestamp(), + "created_at": comment_created_at.timestamp(), + "content": updated_content, + }), + content_type="application/json", + ) + assert response.status_code == 200 + assert response.json["status"] == "ok" + assert response.json["response"]["content"] == updated_content + + +def test_get_comments_should_return_list_of_comments(flask_client): + view_id = str(uuid4()) + highlight_created_at = datetime.now(UTC) + comment_created_at = datetime.now(UTC) + highlight_entry = WidgetHighlights( + view_id=UUID(view_id), + created_at=highlight_created_at, + creator_id=g.user.id, + content="Test highlight", + completed=None, + comments_count=1, + ) + highlight_entry.save() + comment_entry = WidgetComment( + view_id=UUID(view_id), + highlight_at=highlight_created_at, + created_at=comment_created_at, + creator_id=g.user.id, + content="Test comment", + ) + comment_entry.save() + + response = flask_client.get(f"/widgets/highlights/comments?view_id={view_id}&created_at={highlight_created_at.timestamp()}") + assert response.status_code == 200 + assert response.json["status"] == "ok" + comments = response.json["response"] + assert len(comments) == 1 + assert comments[0]["content"] == "Test comment" diff --git a/argus_backend.py b/argus_backend.py index c21d93e3..9e47870f 100644 --- a/argus_backend.py +++ b/argus_backend.py @@ -2,6 +2,7 @@ from flask import Flask from argus.backend.template_filters import export_filters from argus.backend.controller import admin, api, main +from argus.backend.controller.views_widgets import highlights from argus.backend.cli import cli_bp from argus.backend.util.logsetup import setup_application_logging from argus.backend.util.encoders import ArgusJSONEncoder, ArgusJSONProvider @@ -35,6 +36,7 @@ def start_server(config=None) -> Flask: app.register_blueprint(api.bp) app.register_blueprint(admin.bp) app.register_blueprint(cli_bp) + app.register_blueprint(highlights.bp) app.logger.info("Ready.") return app diff --git a/frontend/Common/AssigneeSelector.svelte b/frontend/Common/AssigneeSelector.svelte new file mode 100644 index 00000000..8d7f0eca --- /dev/null +++ b/frontend/Common/AssigneeSelector.svelte @@ -0,0 +1,53 @@ + + + + {#if isEditing} + { if(e.key === 'Enter') updateContent(); }}> + + + {:else} + + {/if} + +
+ {action.createdAt.toLocaleDateString("en-CA")} +
+ +
+ + {#if !action.isArchived} + {#if action.creator_id === currentUserId} + + {/if} + {/if} + +
+ + {#if action.showComments} +
+ + {#if !action.isArchived} +
+ + +
+ {/if} +
+ {/if} + + + diff --git a/frontend/Views/Widgets/ViewHighlights/Comment.svelte b/frontend/Views/Widgets/ViewHighlights/Comment.svelte new file mode 100644 index 00000000..6ae19db5 --- /dev/null +++ b/frontend/Views/Widgets/ViewHighlights/Comment.svelte @@ -0,0 +1,52 @@ + + +
  • +
    + {#if isEditing} + { if(e.key === 'Enter') updateContent(); }}> + + + {:else} +
    +
    + {commentTimeStr} +
    + {/if} +
    +

    {comment.content}

    + {#if comment.creator_id === currentUserId && !isArchived} + + + {/if} +
    +
    +
  • diff --git a/frontend/Views/Widgets/ViewHighlights/HighlightItem.svelte b/frontend/Views/Widgets/ViewHighlights/HighlightItem.svelte new file mode 100644 index 00000000..d6e8a866 --- /dev/null +++ b/frontend/Views/Widgets/ViewHighlights/HighlightItem.svelte @@ -0,0 +1,87 @@ + + +
  • +
    +
    +
    + {#if isEditing} + { if(e.key === 'Enter') updateContent(); }}> + + + {:else} + {highlight.content} + {/if} +
    +
    + {creationTimeStr} + + {#if !highlight.isArchived} + {#if highlight.creator_id === currentUserId} + + {/if} + {/if} + +
    +
    + {#if highlight.showComments} +
    +
      + {#each highlight.comments as comment (comment.id)} + + {/each} +
    + {#if !highlight.isArchived} +
    + + +
    + {/if} +
    + {/if} +
  • diff --git a/frontend/Views/Widgets/ViewHighlights/ViewHighlights.svelte b/frontend/Views/Widgets/ViewHighlights/ViewHighlights.svelte new file mode 100644 index 00000000..d3e522b1 --- /dev/null +++ b/frontend/Views/Widgets/ViewHighlights/ViewHighlights.svelte @@ -0,0 +1,410 @@ + + + +
    + {#key redraw} +
    +
    +

    Highlights

    +
    +
    +
      + {#each highlights.filter(h => !h.isArchived) as highlight (highlight.id)} + + {/each} +
    • + {#if showNewHighlight} +
      + e.key === "Enter" && addEntry("highlight", newHighlight)} + bind:this={newHighlightInput}> + + +
      + {:else} + + {/if} +
    • +
    + {#if highlights.some(h => h.isArchived)} +
    + + {#if showArchivedHighlights} +
      + {#each highlights.filter(h => h.isArchived) as highlight (highlight.id)} + + {/each} +
    + {/if} +
    + {/if} +
    +
    + +
    +
    +

    Action Items

    +
    +
    +
      + {#each actionItems.filter(a => !a.isArchived) as action (action.id)} + + {/each} +
    • + {#if showNewActionItem} +
      + e.key === "Enter" && addEntry("action", newActionItem)} + bind:this={newActionInput}> + + +
      + {:else} + + {/if} +
    • +
    + {#if actionItems.some(a => a.isArchived)} +
    + + {#if showArchivedActionItems} +
      + {#each actionItems.filter(a => a.isArchived) as action (action.id)} + + {/each} +
    + {/if} +
    + {/if} +
    +
    + {/key} +
    diff --git a/frontend/argus.css b/frontend/argus.css index aa0a0804..57de6418 100644 --- a/frontend/argus.css +++ b/frontend/argus.css @@ -152,4 +152,15 @@ div#notificationCounter { --bs-table-hover-color: #000; color: var(--bs-table-color); border-color: var(--bs-table-border-color); -} \ No newline at end of file +} + +.img-profile { + width: 24px; + height: 24px; + border-radius: 50%; + background-color: rgb(163, 163, 163); + background-clip: border-box; + background-repeat: no-repeat; + background-position: center; + background-size: cover; +} diff --git a/poetry.lock b/poetry.lock index 972cc2f4..76d48d0a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -76,6 +76,50 @@ tests = ["attrs[tests-no-zope]", "zope-interface"] tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] +[[package]] +name = "black" +version = "24.10.0" +description = "The uncompromising code formatter." +optional = false +python-versions = ">=3.9" +files = [ + {file = "black-24.10.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6668650ea4b685440857138e5fe40cde4d652633b1bdffc62933d0db4ed9812"}, + {file = "black-24.10.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1c536fcf674217e87b8cc3657b81809d3c085d7bf3ef262ead700da345bfa6ea"}, + {file = "black-24.10.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:649fff99a20bd06c6f727d2a27f401331dc0cc861fb69cde910fe95b01b5928f"}, + {file = "black-24.10.0-cp310-cp310-win_amd64.whl", hash = "sha256:fe4d6476887de70546212c99ac9bd803d90b42fc4767f058a0baa895013fbb3e"}, + {file = "black-24.10.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5a2221696a8224e335c28816a9d331a6c2ae15a2ee34ec857dcf3e45dbfa99ad"}, + {file = "black-24.10.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f9da3333530dbcecc1be13e69c250ed8dfa67f43c4005fb537bb426e19200d50"}, + {file = "black-24.10.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4007b1393d902b48b36958a216c20c4482f601569d19ed1df294a496eb366392"}, + {file = "black-24.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:394d4ddc64782e51153eadcaaca95144ac4c35e27ef9b0a42e121ae7e57a9175"}, + {file = "black-24.10.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b5e39e0fae001df40f95bd8cc36b9165c5e2ea88900167bddf258bacef9bbdc3"}, + {file = "black-24.10.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d37d422772111794b26757c5b55a3eade028aa3fde43121ab7b673d050949d65"}, + {file = "black-24.10.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:14b3502784f09ce2443830e3133dacf2c0110d45191ed470ecb04d0f5f6fcb0f"}, + {file = "black-24.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:30d2c30dc5139211dda799758559d1b049f7f14c580c409d6ad925b74a4208a8"}, + {file = "black-24.10.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cbacacb19e922a1d75ef2b6ccaefcd6e93a2c05ede32f06a21386a04cedb981"}, + {file = "black-24.10.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1f93102e0c5bb3907451063e08b9876dbeac810e7da5a8bfb7aeb5a9ef89066b"}, + {file = "black-24.10.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ddacb691cdcdf77b96f549cf9591701d8db36b2f19519373d60d31746068dbf2"}, + {file = "black-24.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:680359d932801c76d2e9c9068d05c6b107f2584b2a5b88831c83962eb9984c1b"}, + {file = "black-24.10.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:17374989640fbca88b6a448129cd1745c5eb8d9547b464f281b251dd00155ccd"}, + {file = "black-24.10.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:63f626344343083322233f175aaf372d326de8436f5928c042639a4afbbf1d3f"}, + {file = "black-24.10.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfa1d0cb6200857f1923b602f978386a3a2758a65b52e0950299ea014be6800"}, + {file = "black-24.10.0-cp39-cp39-win_amd64.whl", hash = "sha256:2cd9c95431d94adc56600710f8813ee27eea544dd118d45896bb734e9d7a0dc7"}, + {file = "black-24.10.0-py3-none-any.whl", hash = "sha256:3bb2b7a1f7b685f85b11fed1ef10f8a9148bceb49853e47a294a3dd963c1dd7d"}, + {file = "black-24.10.0.tar.gz", hash = "sha256:846ea64c97afe3bc677b761787993be4991810ecc7a4a937816dd6bddedc4875"}, +] + +[package.dependencies] +click = ">=8.0.0" +mypy-extensions = ">=0.4.3" +packaging = ">=22.0" +pathspec = ">=0.9.0" +platformdirs = ">=2" + +[package.extras] +colorama = ["colorama (>=0.4.3)"] +d = ["aiohttp (>=3.10)"] +jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"] +uvloop = ["uvloop (>=0.15.2)"] + [[package]] name = "blinker" version = "1.8.2" @@ -754,6 +798,17 @@ files = [ {file = "multi_key_dict-2.0.3.zip", hash = "sha256:3a1e1fc705a30a7de1a153ec2992b3ca3655ccd9225d2e427fe6525c8f160d6d"}, ] +[[package]] +name = "mypy-extensions" +version = "1.0.0" +description = "Type system extensions for programs checked with the mypy type checker." +optional = false +python-versions = ">=3.5" +files = [ + {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, + {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, +] + [[package]] name = "nodeenv" version = "1.9.0" @@ -791,6 +846,17 @@ files = [ qa = ["flake8 (==5.0.4)", "mypy (==0.971)", "types-setuptools (==67.2.0.1)"] testing = ["docopt", "pytest"] +[[package]] +name = "pathspec" +version = "0.12.1" +description = "Utility library for gitignore style pattern matching of file paths." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, + {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, +] + [[package]] name = "pbr" version = "6.0.0" @@ -1481,4 +1547,4 @@ email = ["email-validator"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "fac2f257f66a0d9957e4c9d124c928b5504ee7a3789b0d68abb6ffab94d5e0ec" +content-hash = "0203953d2b8626d4a2b7fe95fee88cfee568a2b7b6cb7879b9bfe2dc096f2593" diff --git a/pyproject.toml b/pyproject.toml index 8a8d8e91..894f12ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,6 +44,7 @@ python-slugify = "^6.1.1" supervisor = "^4.2.4" [tool.poetry.dev-dependencies] +black = "^24.10.0" coverage = "5.5" docker = "7.1.0" pytest = "6.2.5" @@ -54,3 +55,7 @@ pre-commit = "^1.14.0" [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" + +[tool.black] +line-length = 110 +target-version = ["py312"]