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"]