8bdbe48fbe
Blue Team gets a detect_procedure field on Test (what they actually did to detect the attack), Blue's counterpart to Red's procedure_text. It's seeded from a new detect_suggested_procedure field on TestTemplate at test-creation time, so a junior who later reuses the same template starts with prior guidance instead of a blank field. Also adds the procedure_suggestions review table and Test.source_template_id, laying the groundwork for suggesting template improvements from filled-in procedure fields (reviewed and approved by a lead, never auto-written). detect_procedure is archived (not cleared) on Blue reopen, matching blue_summary, and now appears in the Jira round-archived and blue_review comments alongside the existing detection/containment fields.
656 lines
23 KiB
Python
656 lines
23 KiB
Python
"""Test CRUD service — list, create, update, and query logic for security tests.
|
|
|
|
Framework-agnostic; uses domain exceptions from app.domain.errors.
|
|
The router is responsible for HTTP concerns, auth, audit logging, and commit.
|
|
"""
|
|
|
|
# Import uuid
|
|
import uuid
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
# Import Session, joinedload from sqlalchemy.orm
|
|
from sqlalchemy import func, or_
|
|
from sqlalchemy.orm import Query, Session, joinedload
|
|
|
|
# Import from app.domain.errors
|
|
from app.domain.errors import (
|
|
BusinessRuleViolation,
|
|
EntityNotFoundError,
|
|
PermissionViolation,
|
|
)
|
|
from app.models.enums import DataClassification, TestState
|
|
from app.models.technique import Technique
|
|
from app.models.test import Test
|
|
from app.models.test_template import TestTemplate
|
|
from app.models.campaign import CampaignTest
|
|
from app.models.audit import AuditLog
|
|
from app.utils import escape_like
|
|
|
|
# Tactics whose test data is more likely to touch customer content, PII, or
|
|
# high-impact operational detail — these get an initial classification of
|
|
# 'pii' rather than the 'internal_use_only' baseline. This is a starting
|
|
# point only; the assigned operator or lead can always correct it.
|
|
_RESTRICTED_TACTICS = frozenset({
|
|
"exfiltration", "collection", "credential-access", "impact",
|
|
})
|
|
|
|
|
|
def determine_initial_classification(technique: Technique | None) -> str:
|
|
"""Best-effort initial data classification for a new test.
|
|
|
|
Every security test reveals internal attack/defense posture, so the
|
|
baseline is 'internal_use_only' — never public or general use by
|
|
default. Escalated to 'pii' when the technique's tactic implies
|
|
customer data, PII, or destructive impact.
|
|
"""
|
|
if technique and technique.tactic and technique.tactic.lower() in _RESTRICTED_TACTICS:
|
|
return DataClassification.pii.value
|
|
return DataClassification.internal_use_only.value
|
|
|
|
|
|
def _build_test_query(
|
|
db: Session,
|
|
*,
|
|
state: str | None = None,
|
|
technique_id: uuid.UUID | None = None,
|
|
technique_search: str | None = None,
|
|
platform: str | None = None,
|
|
created_by: uuid.UUID | None = None,
|
|
pending_validation_side: str | None = None,
|
|
reviewer_id: uuid.UUID | None = None,
|
|
not_in_any_campaign: bool = False,
|
|
attack_success: str | None = None,
|
|
detection_result: str | None = None,
|
|
validated_from: datetime | None = None,
|
|
validated_to: datetime | None = None,
|
|
) -> Query:
|
|
"""Build the filtered Test query shared by list_tests() and count_tests().
|
|
|
|
Kept as a single source of truth so the displayed page of results and
|
|
the total count it's paginated against can never drift apart.
|
|
"""
|
|
query = db.query(Test).options(
|
|
joinedload(Test.technique),
|
|
joinedload(Test.red_tech_assigned_user), joinedload(Test.blue_tech_assigned_user),
|
|
)
|
|
|
|
# Check: state
|
|
if state:
|
|
# Assign query = query.filter(Test.state == state)
|
|
query = query.filter(Test.state == state)
|
|
# Check: technique_id
|
|
if technique_id:
|
|
# Assign query = query.filter(Test.technique_id == technique_id)
|
|
query = query.filter(Test.technique_id == technique_id)
|
|
# Free-text technique filter (MITRE ID or name) — doesn't require the
|
|
# caller to already know the technique's UUID like technique_id does.
|
|
if technique_search:
|
|
pattern = f"%{escape_like(technique_search)}%"
|
|
query = query.join(Technique, Test.technique_id == Technique.id).filter(
|
|
or_(
|
|
Technique.mitre_id.ilike(pattern),
|
|
Technique.name.ilike(pattern),
|
|
)
|
|
)
|
|
# Check: platform
|
|
if platform:
|
|
# Assign query = query.filter(Test.platform.ilike(f"%{escape_like(platform)}%"))
|
|
query = query.filter(Test.platform.ilike(f"%{escape_like(platform)}%"))
|
|
# Check: created_by
|
|
if created_by:
|
|
# Assign query = query.filter(Test.created_by == created_by)
|
|
query = query.filter(Test.created_by == created_by)
|
|
# Check: pending_validation_side == "red"
|
|
if pending_validation_side == "red":
|
|
# Assign query = query.filter(
|
|
query = query.filter(
|
|
Test.state == TestState.in_review,
|
|
Test.red_validation_status.in_(["pending", None]),
|
|
)
|
|
# Alternative: pending_validation_side == "blue"
|
|
elif pending_validation_side == "blue":
|
|
# Assign query = query.filter(
|
|
query = query.filter(
|
|
Test.state == TestState.in_review,
|
|
Test.blue_validation_status.in_(["pending", None]),
|
|
)
|
|
# "My reviews" — tests currently assigned to *reviewer_id* at the
|
|
# red_review/blue_review lead-review gate (distinct from
|
|
# pending_validation_side, which covers the later in_review stage).
|
|
if reviewer_id:
|
|
if state == TestState.red_review.value:
|
|
query = query.filter(Test.red_reviewer_assignee == reviewer_id)
|
|
elif state == TestState.blue_review.value:
|
|
query = query.filter(Test.blue_reviewer_assignee == reviewer_id)
|
|
else:
|
|
query = query.filter(
|
|
Test.state.in_([TestState.red_review, TestState.blue_review]),
|
|
or_(
|
|
Test.red_reviewer_assignee == reviewer_id,
|
|
Test.blue_reviewer_assignee == reviewer_id,
|
|
),
|
|
)
|
|
|
|
if not_in_any_campaign:
|
|
linked = db.query(CampaignTest.test_id).distinct().subquery()
|
|
query = query.filter(~Test.id.in_(linked))
|
|
|
|
if attack_success:
|
|
query = query.filter(Test.attack_success == attack_success)
|
|
if detection_result:
|
|
query = query.filter(Test.detection_result == detection_result)
|
|
|
|
# Validated-date range: whichever lead validated last (mirrors the
|
|
# "Validated" column shown on the Validated Tests page).
|
|
if validated_from or validated_to:
|
|
validated_at = func.coalesce(Test.blue_validated_at, Test.red_validated_at)
|
|
if validated_from:
|
|
query = query.filter(validated_at >= validated_from)
|
|
if validated_to:
|
|
query = query.filter(validated_at <= validated_to)
|
|
|
|
return query
|
|
|
|
|
|
# Define function list_tests
|
|
def list_tests(
|
|
db: Session,
|
|
*,
|
|
state: str | None = None,
|
|
technique_id: uuid.UUID | None = None,
|
|
technique_search: str | None = None,
|
|
platform: str | None = None,
|
|
created_by: uuid.UUID | None = None,
|
|
pending_validation_side: str | None = None,
|
|
# Entry: reviewer_id — "my reviews" queue for the red_review/blue_review gate stage
|
|
reviewer_id: uuid.UUID | None = None,
|
|
not_in_any_campaign: bool = False,
|
|
attack_success: str | None = None,
|
|
detection_result: str | None = None,
|
|
validated_from: datetime | None = None,
|
|
validated_to: datetime | None = None,
|
|
offset: int = 0,
|
|
# Entry: limit
|
|
limit: int = 50,
|
|
) -> list[Test]:
|
|
"""Return a paginated list of tests with optional filters."""
|
|
query = _build_test_query(
|
|
db,
|
|
state=state,
|
|
technique_id=technique_id,
|
|
technique_search=technique_search,
|
|
platform=platform,
|
|
created_by=created_by,
|
|
pending_validation_side=pending_validation_side,
|
|
reviewer_id=reviewer_id,
|
|
not_in_any_campaign=not_in_any_campaign,
|
|
attack_success=attack_success,
|
|
detection_result=detection_result,
|
|
validated_from=validated_from,
|
|
validated_to=validated_to,
|
|
)
|
|
return query.order_by(Test.created_at.desc()).offset(offset).limit(limit).all()
|
|
|
|
|
|
def count_tests(
|
|
db: Session,
|
|
*,
|
|
state: str | None = None,
|
|
technique_id: uuid.UUID | None = None,
|
|
technique_search: str | None = None,
|
|
platform: str | None = None,
|
|
created_by: uuid.UUID | None = None,
|
|
pending_validation_side: str | None = None,
|
|
reviewer_id: uuid.UUID | None = None,
|
|
not_in_any_campaign: bool = False,
|
|
attack_success: str | None = None,
|
|
detection_result: str | None = None,
|
|
validated_from: datetime | None = None,
|
|
validated_to: datetime | None = None,
|
|
) -> int:
|
|
"""Return the total count of tests matching the same filters as list_tests().
|
|
|
|
Lets the frontend show a true total even when the page size caps the
|
|
number of rows actually returned (e.g. GET /tests's limit<=200).
|
|
"""
|
|
query = _build_test_query(
|
|
db,
|
|
state=state,
|
|
technique_id=technique_id,
|
|
technique_search=technique_search,
|
|
platform=platform,
|
|
created_by=created_by,
|
|
pending_validation_side=pending_validation_side,
|
|
reviewer_id=reviewer_id,
|
|
not_in_any_campaign=not_in_any_campaign,
|
|
attack_success=attack_success,
|
|
detection_result=detection_result,
|
|
validated_from=validated_from,
|
|
validated_to=validated_to,
|
|
)
|
|
return query.count()
|
|
|
|
|
|
# Define function create_test
|
|
def create_test(
|
|
# Entry: db
|
|
db: Session,
|
|
*,
|
|
# Entry: technique_id
|
|
technique_id: uuid.UUID,
|
|
# Entry: creator_id
|
|
creator_id: uuid.UUID,
|
|
**fields: object,
|
|
) -> Test:
|
|
"""Create a new test linked to an existing technique.
|
|
|
|
Raises EntityNotFoundError if the technique does not exist.
|
|
Does not commit; caller uses UnitOfWork.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
technique_id (uuid.UUID): UUID of the technique this test covers.
|
|
creator_id (uuid.UUID): UUID of the user creating the test.
|
|
**fields (object): Additional keyword arguments set as attributes on
|
|
the new test (e.g. ``name``, ``platform``, ``description``).
|
|
|
|
Returns:
|
|
Test: The newly created test ORM object, flushed but not committed.
|
|
"""
|
|
# Assign technique = db.query(Technique).filter(Technique.id == technique_id).first()
|
|
technique = db.query(Technique).filter(Technique.id == technique_id).first()
|
|
# Check: technique is None
|
|
if technique is None:
|
|
# Raise EntityNotFoundError
|
|
raise EntityNotFoundError("Technique", str(technique_id))
|
|
|
|
data_classification = fields.pop("data_classification", None) or determine_initial_classification(technique)
|
|
|
|
# Assign test = Test(
|
|
test = Test(
|
|
# Keyword argument: technique_id
|
|
technique_id=technique_id,
|
|
# Keyword argument: created_by
|
|
created_by=creator_id,
|
|
# Keyword argument: state
|
|
state=TestState.draft,
|
|
created_at=datetime.utcnow(), # explicit — DB column has no server default
|
|
data_classification=data_classification,
|
|
**fields,
|
|
)
|
|
# Stage new record(s) for database insertion
|
|
db.add(test)
|
|
# Flush changes to DB without committing the transaction
|
|
db.flush()
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# Define function create_test_from_template
|
|
def create_test_from_template(
|
|
# Entry: db
|
|
db: Session,
|
|
*,
|
|
# Entry: template_id
|
|
template_id: uuid.UUID,
|
|
# Entry: technique_id_or_mitre
|
|
technique_id_or_mitre: str,
|
|
# Entry: creator_id
|
|
creator_id: uuid.UUID,
|
|
# Optional user-edited overrides (take priority over template values)
|
|
name_override: str | None = None,
|
|
description_override: str | None = None,
|
|
platform_override: str | None = None,
|
|
procedure_text_override: str | None = None,
|
|
tool_used_override: str | None = None,
|
|
) -> Test:
|
|
"""Instantiate a Test from a TestTemplate.
|
|
|
|
technique_id_or_mitre can be a UUID string or MITRE ID (e.g. T1059.001).
|
|
Override fields, when provided, take precedence over the template's values.
|
|
Raises EntityNotFoundError if template or technique not found.
|
|
Does not commit; caller uses UnitOfWork.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
template_id (uuid.UUID): UUID of the template to instantiate.
|
|
technique_id_or_mitre (str): UUID string or MITRE technique ID
|
|
(e.g. ``"T1059.001"``) identifying the target technique.
|
|
creator_id (uuid.UUID): UUID of the user creating the test.
|
|
|
|
Returns:
|
|
Test: The newly created test populated from template fields, flushed
|
|
but not committed.
|
|
"""
|
|
# Assign template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first()
|
|
template = db.query(TestTemplate).filter(TestTemplate.id == template_id).first()
|
|
# Check: template is None
|
|
if template is None:
|
|
# Raise EntityNotFoundError
|
|
raise EntityNotFoundError("TestTemplate", str(template_id))
|
|
|
|
# Assign technique = None
|
|
technique = None
|
|
# Attempt the following; catch errors below
|
|
try:
|
|
# Assign technique_uuid = uuid.UUID(technique_id_or_mitre)
|
|
technique_uuid = uuid.UUID(technique_id_or_mitre)
|
|
# Assign technique = db.query(Technique).filter(Technique.id == technique_uuid).first()
|
|
technique = db.query(Technique).filter(Technique.id == technique_uuid).first()
|
|
# Handle ValueError
|
|
except ValueError:
|
|
# Intentional no-op placeholder
|
|
pass
|
|
|
|
# Check: technique is None
|
|
if technique is None:
|
|
# Assign technique = db.query(Technique).filter(
|
|
technique = db.query(Technique).filter(
|
|
Technique.mitre_id == technique_id_or_mitre
|
|
).first()
|
|
|
|
# Check: technique is None
|
|
if technique is None:
|
|
# Raise EntityNotFoundError
|
|
raise EntityNotFoundError("Technique", technique_id_or_mitre)
|
|
|
|
# Assign test = Test(
|
|
test = Test(
|
|
# Keyword argument: technique_id
|
|
technique_id=technique.id,
|
|
name=name_override if name_override is not None else template.name,
|
|
description=description_override if description_override is not None else template.description,
|
|
platform=platform_override if platform_override is not None else template.platform,
|
|
procedure_text=procedure_text_override if procedure_text_override is not None else template.attack_procedure,
|
|
tool_used=tool_used_override if tool_used_override is not None else template.tool_suggested,
|
|
detect_procedure=template.detect_suggested_procedure,
|
|
remediation_steps=template.suggested_remediation,
|
|
# Keyword argument: created_by
|
|
created_by=creator_id,
|
|
source_template_id=template.id,
|
|
# Keyword argument: state
|
|
state=TestState.draft,
|
|
created_at=datetime.utcnow(), # explicit — DB column has no server default
|
|
data_classification=determine_initial_classification(technique),
|
|
)
|
|
# Stage new record(s) for database insertion
|
|
db.add(test)
|
|
# Flush changes to DB without committing the transaction
|
|
db.flush()
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# Define function get_test_detail
|
|
def get_test_detail(db: Session, test_id: uuid.UUID) -> Test:
|
|
"""Fetch a test with evidences and technique eager-loaded.
|
|
|
|
Raises EntityNotFoundError if the test does not exist.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test_id (uuid.UUID): UUID of the test to retrieve.
|
|
|
|
Returns:
|
|
Test: The test ORM object with ``evidences`` relationship loaded.
|
|
"""
|
|
# Assign test = (
|
|
test = (
|
|
db.query(Test)
|
|
.options(
|
|
joinedload(Test.evidences), joinedload(Test.technique),
|
|
joinedload(Test.round_history),
|
|
joinedload(Test.red_tech_assigned_user), joinedload(Test.blue_tech_assigned_user),
|
|
joinedload(Test.red_reviewer), joinedload(Test.blue_reviewer),
|
|
)
|
|
.filter(Test.id == test_id)
|
|
# Chain .first() call
|
|
.first()
|
|
)
|
|
# Check: test is None
|
|
if test is None:
|
|
# Raise EntityNotFoundError
|
|
raise EntityNotFoundError("Test", str(test_id))
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# Define function get_test_or_raise
|
|
def get_test_or_raise(db: Session, test_id: uuid.UUID) -> Test:
|
|
"""Fetch a test by ID. Raises EntityNotFoundError if not found.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test_id (uuid.UUID): UUID of the test to retrieve.
|
|
|
|
Returns:
|
|
Test: The matching test ORM object.
|
|
"""
|
|
# Assign test = db.query(Test).filter(Test.id == test_id).first()
|
|
test = db.query(Test).filter(Test.id == test_id).first()
|
|
# Check: test is None
|
|
if test is None:
|
|
# Raise EntityNotFoundError
|
|
raise EntityNotFoundError("Test", str(test_id))
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# Define function get_test_with_technique
|
|
def get_test_with_technique(db: Session, test_id: uuid.UUID) -> Test:
|
|
"""Fetch a test with technique joined. Raises EntityNotFoundError if not found.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test_id (uuid.UUID): UUID of the test to retrieve.
|
|
|
|
Returns:
|
|
Test: The test ORM object with ``technique`` relationship loaded.
|
|
"""
|
|
# Assign test = (
|
|
test = (
|
|
db.query(Test)
|
|
# Chain .options() call
|
|
.options(joinedload(Test.technique))
|
|
# Chain .filter() call
|
|
.filter(Test.id == test_id)
|
|
# Chain .first() call
|
|
.first()
|
|
)
|
|
# Check: test is None
|
|
if test is None:
|
|
# Raise EntityNotFoundError
|
|
raise EntityNotFoundError("Test", str(test_id))
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# Define function update_test
|
|
def update_test(
|
|
# Entry: db
|
|
db: Session,
|
|
# Entry: test_id
|
|
test_id: uuid.UUID,
|
|
*,
|
|
# Entry: updater_id
|
|
updater_id: uuid.UUID,
|
|
# Entry: updater_role
|
|
updater_role: str,
|
|
**fields: object,
|
|
) -> Test:
|
|
"""Update general test fields (draft or rejected only).
|
|
|
|
Raises PermissionViolation if not creator or admin.
|
|
Raises BusinessRuleViolation if state is not draft or rejected.
|
|
Raises EntityNotFoundError if test not found.
|
|
Does not commit; caller uses UnitOfWork.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test_id (uuid.UUID): UUID of the test to update.
|
|
updater_id (uuid.UUID): UUID of the user performing the update.
|
|
updater_role (str): Role of the updater; ``"admin"`` bypasses the
|
|
creator-only restriction.
|
|
**fields (object): Keyword arguments mapped directly onto test
|
|
attributes.
|
|
|
|
Returns:
|
|
Test: The updated test ORM object, flushed but not committed.
|
|
"""
|
|
# Assign test = get_test_or_raise(db, test_id)
|
|
test = get_test_or_raise(db, test_id)
|
|
|
|
# Check: updater_role != "admin" and test.created_by != updater_id
|
|
if updater_role != "admin" and test.created_by != updater_id:
|
|
# Raise PermissionViolation
|
|
raise PermissionViolation(
|
|
# Literal argument value
|
|
"Only the test creator or an admin can update this test"
|
|
)
|
|
|
|
# Check: test.state not in (TestState.draft, TestState.rejected)
|
|
if test.state not in (TestState.draft, TestState.rejected):
|
|
# Raise BusinessRuleViolation
|
|
raise BusinessRuleViolation(
|
|
f"Cannot update a test in '{test.state.value}' state (must be draft or rejected)"
|
|
)
|
|
|
|
# Iterate over fields.items()
|
|
for field, value in fields.items():
|
|
# Call setattr()
|
|
setattr(test, field, value)
|
|
|
|
# Flush changes to DB without committing the transaction
|
|
db.flush()
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# Define function update_test_red
|
|
def update_test_red(db: Session, test_id: uuid.UUID, **fields: object) -> Test:
|
|
"""Update Red Team fields (draft or red_executing only).
|
|
|
|
Raises BusinessRuleViolation if state not in (draft, red_executing).
|
|
Raises EntityNotFoundError if test not found.
|
|
Does not commit; caller uses UnitOfWork.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test_id (uuid.UUID): UUID of the test to update.
|
|
**fields (object): Red-team field names and their new values.
|
|
|
|
Returns:
|
|
Test: The updated test ORM object, flushed but not committed.
|
|
"""
|
|
# Assign test = get_test_or_raise(db, test_id)
|
|
test = get_test_or_raise(db, test_id)
|
|
|
|
# Check: test.state not in (TestState.draft, TestState.red_executing)
|
|
if test.state not in (TestState.draft, TestState.red_executing):
|
|
# Raise BusinessRuleViolation
|
|
raise BusinessRuleViolation(
|
|
f"Cannot update red fields in '{test.state.value}' state "
|
|
# Literal argument value
|
|
"(must be draft or red_executing)"
|
|
)
|
|
|
|
# Iterate over fields.items()
|
|
for field, value in fields.items():
|
|
# Call setattr()
|
|
setattr(test, field, value)
|
|
|
|
# Flush changes to DB without committing the transaction
|
|
db.flush()
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# Define function update_test_blue
|
|
def update_test_blue(db: Session, test_id: uuid.UUID, **fields: object) -> Test:
|
|
"""Update Blue Team fields (blue_evaluating only).
|
|
|
|
Raises BusinessRuleViolation if state is not blue_evaluating.
|
|
Raises EntityNotFoundError if test not found.
|
|
Does not commit; caller uses UnitOfWork.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test_id (uuid.UUID): UUID of the test to update.
|
|
**fields (object): Blue-team field names and their new values.
|
|
|
|
Returns:
|
|
Test: The updated test ORM object, flushed but not committed.
|
|
"""
|
|
# Assign test = get_test_or_raise(db, test_id)
|
|
test = get_test_or_raise(db, test_id)
|
|
|
|
# Check: test.state != TestState.blue_evaluating
|
|
if test.state != TestState.blue_evaluating:
|
|
# Raise BusinessRuleViolation
|
|
raise BusinessRuleViolation(
|
|
f"Cannot update blue fields in '{test.state.value}' state "
|
|
# Literal argument value
|
|
"(must be blue_evaluating)"
|
|
)
|
|
|
|
# Iterate over fields.items()
|
|
for field, value in fields.items():
|
|
# Call setattr()
|
|
setattr(test, field, value)
|
|
|
|
# Flush changes to DB without committing the transaction
|
|
db.flush()
|
|
# Return test
|
|
return test
|
|
|
|
|
|
# Define function get_test_timeline
|
|
def get_test_timeline(db: Session, test_id: uuid.UUID) -> list[dict[str, Any]]:
|
|
"""Return chronological audit-log history for a test.
|
|
|
|
Raises EntityNotFoundError if the test does not exist.
|
|
|
|
Args:
|
|
db (Session): Active SQLAlchemy database session.
|
|
test_id (uuid.UUID): UUID of the test whose history is requested.
|
|
|
|
Returns:
|
|
list[dict[str, Any]]: Audit-log entries ordered by timestamp ascending,
|
|
each containing ``id``, ``action``, ``user_id``, ``timestamp``,
|
|
and ``details``.
|
|
"""
|
|
# Call get_test_or_raise()
|
|
get_test_or_raise(db, test_id)
|
|
|
|
# Assign logs = (
|
|
logs = (
|
|
db.query(AuditLog)
|
|
# Chain .filter() call
|
|
.filter(
|
|
AuditLog.entity_type == "test",
|
|
AuditLog.entity_id == str(test_id),
|
|
)
|
|
# Chain .order_by() call
|
|
.order_by(AuditLog.timestamp.asc())
|
|
# Chain .all() call
|
|
.all()
|
|
)
|
|
|
|
# Return [
|
|
return [
|
|
{
|
|
# Literal argument value
|
|
"id": str(log.id),
|
|
# Literal argument value
|
|
"action": log.action,
|
|
# Literal argument value
|
|
"user_id": str(log.user_id) if log.user_id else None,
|
|
# Literal argument value
|
|
"timestamp": log.timestamp.isoformat() if log.timestamp else None,
|
|
# Literal argument value
|
|
"details": log.details,
|
|
}
|
|
for log in logs
|
|
]
|