Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
48bee2b
chore: update backend and frontend dependencies, fix biome nextTick h…
fxai Oct 26, 2026
e698174
fix(frontend): align selection checkboxes in grouped activity table
fxai Oct 26, 2026
606a5b1
fix(frontend): show empty activity groups in the sidebar
fxai Oct 26, 2026
d9e6a5b
fix(frontend): make tag suggestions dropdown scrollable
fxai Oct 26, 2026
6ab39bf
fix(frontend): keep eval result toggles below the sticky activity header
fxai Oct 26, 2026
0c44830
feat(frontend): add phone, email, app and website asset icons
fxai Oct 26, 2026
8aa7258
feat(frontend): keep dialogs open when clicking outside
fxai Oct 26, 2026
93279be
feat(frontend): show tag pills next to activities in the sidebar
fxai Oct 26, 2026
fd2c774
fix(frontend): use outlined tag style in the activity tag input
fxai Oct 26, 2026
edc768c
feat(frontend): quick-add and direct edit for assets in the activity …
fxai Oct 26, 2026
07ce756
feat(frontend): organise activity form sections into browser-style tabs
fxai Oct 26, 2026
b51628d
feat(frontend): reset view between activity selections, also reset sc…
fxai Oct 26, 2026
0e575b9
feat(frontend): reorder activities and groups by drag and drop in the…
fxai Oct 26, 2026
0c72112
fix(frontend): exclude deleted activities from Manage Order
fxai Oct 26, 2026
2170c06
feat(backend): allow setting an explicit state on the activity visibi…
fxai Oct 26, 2026
4d66c47
feat(frontend): explicit Show/Hide bulk visibility and keep table sel…
fxai Oct 26, 2026
6fbd0a2
feat(backend): allow choosing the group when creating an activity
fxai Oct 26, 2026
10bb011
feat(frontend): choose activity group in the Create Activity dialog
fxai Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 15 additions & 4 deletions backend/app/api/v1/assessments/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@
from app.enums.enums import AclRole, ActivityAssetRole
from app.models.user import User
from app.schemas.activity import (
ActivityBase,
ActivityCreate,
ActivityFilter,
ActivityRead,
ActivityUpdate,
ActivityVisibleUpdate,
)
from app.schemas.activity_evaluation_dynamic_questions import (
DynamicEvaluationQuestionAssign,
Expand Down Expand Up @@ -90,7 +91,7 @@ def get_activity_by_id(

@router.post("/", response_model=ActivityRead)
def create_activity(
activity: ActivityBase,
activity: ActivityCreate,
assessment_id: uuid.UUID,
user: User = Depends(require_assessment_role(AclRole.RED)),
session: Session = Depends(get_session),
Expand Down Expand Up @@ -208,11 +209,21 @@ def assign_update_assets_to_activity(
def toggle_visible_activity(
activity_id: uuid.UUID,
assessment_id: uuid.UUID,
visibility: ActivityVisibleUpdate | None = None,
user: User = Depends(require_assessment_role(AclRole.RED)),
session: Session = Depends(get_session),
):
toggle_visible_activity_service(activity_id, assessment_id, user, session)
return MessageResponse(message="Activity visible state toggled successfully")
"""
Without a body: toggle the visible state.
With {"visible": true|false}: set that state (idempotent).
"""
if visibility is None:
toggle_visible_activity_service(activity_id, assessment_id, user, session)
return MessageResponse(message="Activity visible state toggled successfully")
toggle_visible_activity_service(
activity_id, assessment_id, user, session, visible=visibility.visible
)
return MessageResponse(message="Activity visible state set successfully")


@router.put("/{activity_id}/dynamic_evaluation_questions", response_model=ActivityRead)
Expand Down
30 changes: 30 additions & 0 deletions backend/app/schemas/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,26 @@ class ActivityBase(BaseModel):
)


class ActivityCreate(ActivityBase):
"""
Assessment activity create model
"""

# Group to add the activity to; the assessment's default group when omitted
activity_group_id: uuid.UUID | None = None

model_config = ConfigDict(
json_schema_extra={
"example": {
"name": "Windows: Malicious Link Execution on Client",
"mitre_tactic": "TA0002",
"mitre_technique": "T1204.001",
"activity_group_id": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
}
},
)


class ActivityUpdate(ActivityBase):
"""
Assessment activity update model
Expand Down Expand Up @@ -290,3 +310,13 @@ class ActivityUpdateBlue(BaseModel):
stakeholder_notification_sources: list[uuid.UUID] | None = None
tags: list[uuid.UUID] | None = None
updated_at: datetime | None = None


class ActivityVisibleUpdate(BaseModel):
"""
Schema for setting an activity's visible state explicitly
"""

visible: bool

model_config = ConfigDict(json_schema_extra={"example": {"visible": True}})
42 changes: 27 additions & 15 deletions backend/app/services/activity/activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from app.models.evaluation_template import EvaluationTemplate
from app.models.user import User
from app.schemas.activity import (
ActivityBase,
ActivityCreate,
ActivityFilter,
ActivityRead,
ActivityUpdate,
Expand Down Expand Up @@ -141,24 +141,31 @@ def get_activity_by_id_service(


def create_activity_service(
activity: ActivityBase, assessment_id: uuid.UUID, user: User, session: Session
activity: ActivityCreate, assessment_id: uuid.UUID, user: User, session: Session
) -> Activity:
"""
Create a new activity.
Create a new activity at the end of the given group, or of the
assessment's default group when no group is given.
"""
from app.services.activity_group.activity_group import get_or_create_default_group

default_group = get_or_create_default_group(
assessment_id, session, created_by=user.id
from app.services.activity_group.activity_group import (
get_activity_group_by_id_service,
get_or_create_default_group,
)
new_position = calculate_new_activity_position(session, default_group.id)

if activity.activity_group_id:
group = get_activity_group_by_id_service(
activity.activity_group_id, assessment_id, user, session
)
else:
group = get_or_create_default_group(assessment_id, session, created_by=user.id)
new_position = calculate_new_activity_position(session, group.id)

activity_db = Activity(
assessment_id=assessment_id,
name=activity.name,
mitre_tactic=activity.mitre_tactic,
mitre_technique=activity.mitre_technique,
activity_group_id=default_group.id,
activity_group_id=group.id,
activity_position=new_position,
created_by=user.id,
)
Expand Down Expand Up @@ -430,17 +437,22 @@ def toggle_delete_activity_service(


def toggle_visible_activity_service(
activity_id: uuid.UUID, assessment_id: uuid.UUID, user: User, session: Session
activity_id: uuid.UUID,
assessment_id: uuid.UUID,
user: User,
session: Session,
visible: bool | None = None,
) -> None:
"""
Toggle visible state of an activity
Toggle visible state of an activity, or set it to `visible` when given.
Setting the current state again changes nothing (idempotent).
"""
activity_db = get_activity_by_id_service(activity_id, assessment_id, user, session)

if activity_db.visible:
activity_db.visible = False
else:
activity_db.visible = True
new_visible = not activity_db.visible if visible is None else visible
if activity_db.visible == new_visible:
return
activity_db.visible = new_visible
activity_db.updated_by = user.id
activity_db.updated_at = func.now()
session.commit()
Expand Down
81 changes: 81 additions & 0 deletions backend/tests/api/v1/assessments/test_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -417,3 +417,84 @@ def test_delete_activity(
# Verify soft delete - activity should be marked as deleted
session.refresh(activity)
# Note: Activity model might need deleted field - check model definition


def test_visible_endpoint_toggle_and_explicit_state(
client: TestClient,
test_assessment: Assessment,
test_acl_red: Acl,
auth_headers_regular: dict[str, str],
):
"""Without a body the state toggles; with {"visible": x} it is set to x"""
base_url = f"/api/v1/assessments/{test_assessment.id}/activity"
response = client.post(
f"{base_url}/",
json={
"name": "Test Activity",
"mitre_tactic": "Execution",
"mitre_technique": "T1204.001",
},
headers=auth_headers_regular,
)
assert response.status_code == 200
activity_id = response.json()["id"]
assert response.json()["visible"] is False

def current_visible() -> bool:
response = client.get(f"{base_url}/{activity_id}", headers=auth_headers_regular)
return response.json()["visible"]

# Toggle (no body)
response = client.put(
f"{base_url}/{activity_id}/visible", headers=auth_headers_regular
)
assert response.status_code == 200
assert current_visible() is True

# Explicit state, sent twice: stays visible
for _ in range(2):
response = client.put(
f"{base_url}/{activity_id}/visible",
json={"visible": True},
headers=auth_headers_regular,
)
assert response.status_code == 200
assert current_visible() is True

response = client.put(
f"{base_url}/{activity_id}/visible",
json={"visible": False},
headers=auth_headers_regular,
)
assert response.status_code == 200
assert current_visible() is False


def test_create_activity_in_group(
client: TestClient,
test_assessment: Assessment,
test_acl_red: Acl,
auth_headers_regular: dict[str, str],
):
"""Test creating an activity directly in a chosen activity group"""
base_url = f"/api/v1/assessments/{test_assessment.id}"
response = client.post(
f"{base_url}/activity_group/",
json={"name": "Target Group"},
headers=auth_headers_regular,
)
assert response.status_code == 200
group_id = response.json()["id"]

response = client.post(
f"{base_url}/activity/",
json={
"name": "Grouped Activity",
"mitre_tactic": "Execution",
"mitre_technique": "T1204.001",
"activity_group_id": group_id,
},
headers=auth_headers_regular,
)
assert response.status_code == 200
assert response.json()["activity_group_id"] == group_id
16 changes: 16 additions & 0 deletions backend/tests/api/v1/assessments/test_activity_acl.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,22 @@ def test_upload_matching_extension_unchanged(
assert stored.filename == "photo.png"


def test_blue_user_cannot_set_visible(
client: TestClient,
test_assessment: Assessment,
test_activity: Activity,
auth_headers_blue: dict[str, str],
test_acl_blue: Acl,
):
"""Test Blue user cannot change an activity's visibility"""
response = client.put(
f"/api/v1/assessments/{test_assessment.id}/activity/{test_activity.id}/visible",
json={"visible": False},
headers=auth_headers_blue,
)
assert response.status_code == 403


def test_blue_user_fail_hidden(
client: TestClient,
session: Session,
Expand Down
Loading
Loading