Ctrl K
ComunidadGuías…

issues/models.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
import json
import uuid
from collections import Counter
from functools import partial

from django.db import models, transaction
from django.db.models import Case, F, Q, Value, When
from django.db.models.functions import Concat
from django.template.defaultfilters import date as default_date_filter
from django.conf import settings
from django.utils.functional import cached_property
from django.utils import timezone
from django.utils.translation import gettext_lazy as _
from rest_framework.exceptions import ValidationError

from bugsink.period_utils import add_periods_to_datetime
from bugsink.utils import assert_
from bugsink.volume_based_condition import VolumeBasedCondition
from bugsink.transaction import delay_on_commit
from alerts.tasks import send_unmute_alert
from compat.timestamp import parse_timestamp, format_timestamp
from tags.models import IssueTag, TagValue

from .grouping_mechanisms import GROUPING_CHOICES
from .utils import (
    parse_lines, serialize_lines, filter_qs_for_fixed_at, exclude_qs_for_fixed_at,
    get_title_for_exception_type_and_value, LOG_MESSAGE_TYPE)

from .tasks import delete_issue_deps


class IncongruentStateException(Exception):
    pass


class Issue(models.Model):
    """
    An Issue models a group of similar events. In particular: it models the result of both automatic (client-side and
    server-side) and manual ("merge") grouping.
    """

    id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)

    project = models.ForeignKey(
        "projects.Project", blank=False, null=False, on_delete=models.DO_NOTHING)

    is_deleted = models.BooleanField(default=False)

    # 1-based for the same reasons as Event.digest_order
    digest_order = models.PositiveIntegerField(blank=False, null=False)

    # denormalized/cached fields:
    last_seen = models.DateTimeField(blank=False, null=False)  # based on event.ingested_at
    first_seen = models.DateTimeField(blank=False, null=False)  # based on event.ingested_at
    digested_event_count = models.IntegerField(blank=False, null=False)
    stored_event_count = models.IntegerField(blank=False, null=False, default=0, editable=False)
    calculated_type = models.CharField(max_length=128, blank=True, null=False, default="")
    calculated_value = models.TextField(max_length=1024, blank=True, null=False, default="")
    transaction = models.CharField(max_length=200, blank=True, null=False, default="")
    last_frame_filename = models.CharField(max_length=255, blank=True, null=False, default="")
    last_frame_module = models.CharField(max_length=255, blank=True, null=False, default="")
    last_frame_function = models.CharField(max_length=255, blank=True, null=False, default="")

    # fields related to resolution:
    # what does this mean for the release-based use cases? it means what you filter on.
    # it also simply means: it was "marked as resolved" after the last regression (if any)
    is_resolved = models.BooleanField(default=False)
    is_resolved_unconditionally = models.BooleanField(default=False)
    is_resolved_by_next_release = models.BooleanField(default=False)

    fixed_at = models.TextField(blank=True, null=False, default='')  # line-separated list
    events_at = models.TextField(blank=True, null=False, default='')  # line-separated list

    # fields related to muting:
    is_muted = models.BooleanField(default=False)
    unmute_on_volume_based_conditions = models.TextField(blank=False, null=False, default="[]")  # json string
    unmute_after = models.DateTimeField(blank=True, null=True)
    next_unmute_check = models.PositiveIntegerField(null=False, default=0)

    def save(self, *args, **kwargs):
        if self.digest_order is None:
            # testing-only; in production this should never happen and instead have been done in the ingest view.
            max_current = self.digest_order = Issue.objects.filter(project=self.project).aggregate(
                models.Max("digest_order"))["digest_order__max"]
            self.digest_order = max_current + 1 if max_current is not None else 1
        super().save(*args, **kwargs)

    def delete_deferred(self):
        """Marks the issue as deleted, and schedules deletion of all related objects"""
        if self.is_deleted:
            return  # quick check for performance; also: idempotency w.r.t. updates to counts.

        self.is_deleted = True
        mark_issues_for_deletion([(self.id, self.project_id)])
        delay_on_commit(delete_issue_deps, str(self.project_id), str(self.id))

    def friendly_id(self):
        return f"{ self.project.slug.upper() }-{ self.digest_order }"

    def get_absolute_url(self):
        return f"/issues/issue/{ self.id }/event/last/"

    def title(self):
        return get_title_for_exception_type_and_value(self.calculated_type, self.calculated_value)

    def is_log_message(self):
        # Ingest keeps calculated_type in sync with the issue's latest event.
        return self.calculated_type == LOG_MESSAGE_TYPE

    def get_fixed_at(self):
        return parse_lines(self.fixed_at)

    def get_events_at(self):
        return parse_lines(self.events_at)

    def get_events_at_2(self):
        # _2: a great Python tradition; in this case: the same as get_events_at(), but ignoring the 'no release' release
        return [e for e in self.get_events_at() if e != ""]

    def add_fixed_at(self, release_version):
        # release_version: str
        fixed_at = self.get_fixed_at()
        if release_version not in fixed_at:
            fixed_at.append(release_version)
            self.fixed_at = serialize_lines(fixed_at)

    def get_unmute_on_volume_based_conditions(self):
        return [
            VolumeBasedCondition.from_dict(vbc_s)
            for vbc_s in json.loads(self.unmute_on_volume_based_conditions)
        ]

    def occurs_in_last_release(self):
        # we can depend on latest_release to exist, because we always create at least one release, even for 'no release'
        latest_release = self.project.get_latest_release()
        return latest_release.version in self.events_at

    def turningpoint_set_all(self):
        # like turningpoint_set.all() but with user in select_related
        return self.turningpoint_set.all().select_related("user")

    @cached_property
    def tags_summary(self):
        return self._get_issue_tags(4, "...")

    @cached_property
    def tags_all(self):
        # NOTE: Having 25 as a cut-off means there's no way to see all tags when there's more than 25; the way to do
        # that would be to have a per-key (per issue) page (paginated); for now I don't see the value in that TBH,
        # because you're well past "this is something I can eyeball-analyse" territory at that point.
        return self._get_issue_tags(25, "Other...")

    def _get_issue_tags(self, other_cutoff, other_label):
        result = []

        if self.digested_event_count > other_cutoff:
            base_qs = self.tags.filter(key__mostly_unique=False)
        else:
            # for low-event-count issues, we just show all tags and their values; we _can_ just do it because there's
            # not too many, and it's actually useful (and maybe even what you expect).
            base_qs = self.tags

        ds = base_qs.values("key")\
            .annotate(count_sum=models.Sum("count"))\
            .distinct()\
            .order_by("key__key")

        for d in ds:
            issue_tags = [
                issue_tag
                for issue_tag in
                (IssueTag.objects
                 .filter(issue=self, key=d['key'])  # note: project is implied through issue
                 .order_by("-count")
                 .select_related("value", "key")[:other_cutoff + 1]  # +1 to see if we need to add "Other"
                 )
            ]

            total_seen = d["count_sum"]

            seen_till_now = 0
            if len(issue_tags) > other_cutoff:
                issue_tags = issue_tags[:other_cutoff - 1]  # cut off one more to make room for "Other"

            for i, issue_tag in enumerate(issue_tags):
                issue_tag.pct = int(issue_tag.count / total_seen * 100)
                seen_till_now += issue_tag.count

            if seen_till_now < total_seen:
                issue_tags.append({
                    "value": TagValue(value=other_label),
                    "count": total_seen - seen_till_now,
                    "pct": int((total_seen - seen_till_now) / total_seen * 100),
                    "is_other": True,
                })

            result.append(issue_tags)

        return result

    class Meta:
        unique_together = [
            ("project", "digest_order"),
        ]
        indexes = [
            # Resolved and muted cannot overlap. The list queries include the implied condition: resolved also filters
            # on is_muted=False, muted also filters on is_resolved=False, and open already filters on both fields. This
            # lets open, muted, and resolved share the (is_resolved, is_muted, ordering) index.
            #
            # Unresolved only fixes is_resolved. Because is_muted comes before the ordering field, that index cannot
            # return open and muted issues together in the requested order. Unresolved therefore scans the broad
            # ordering index and skips resolved issues. A sparse or empty unresolved list can scan most or all of the
            # relevant range. This is accepted because unresolved is the least interesting tab and should work well
            # enough in practice.
            # Note: no is_deleted here; basic assumption is:
            # is_deleted=True are such a minority that a post-index filter is more efficient than having more indexes.
            # See 7b340fd8ff1d.
            # `issue_list_open` is also used for project-level open-issue counting (same where-clause prefix).
            models.Index(fields=["project", "is_resolved", "is_muted", "last_seen"], name="issue_list_open"),
            models.Index(fields=["project", "last_seen"], name="issue_list_all"),

            # Equivalent indexes for event-count sorting; last_seen breaks count ties.
            models.Index(
                fields=["project", "is_resolved", "is_muted", "digested_event_count", "last_seen"],
                name="issue_count_open",
            ),
            models.Index(
                fields=["project", "digested_event_count", "last_seen"],
                name="issue_count_all",
            ),

            # Same filters/orderings for the global issue list, where the page is intentionally not anchored on one
            # project. As above, no is_deleted: deleted issues should be a small minority.
            models.Index(fields=["is_resolved", "is_muted", "last_seen"], name="issue_global_open"),
            models.Index(fields=["last_seen"], name="issue_global_all"),

            # Equivalent indexes for event-count sorting across projects.
            models.Index(
                fields=["is_resolved", "is_muted", "digested_event_count", "last_seen"],
                name="issue_global_count_open",
            ),
            models.Index(
                fields=["digested_event_count", "last_seen"],
                name="issue_global_count_all",
            ),
        ]


def issue_lookup_kwargs(value):
    try:
        uuid.UUID(str(value))
        return {"id": value}
    except ValueError:
        pass

    try:
        project_slug, digest_order = str(value).rsplit("-", 1)
        digest_order = int(digest_order)
    except ValueError:
        raise ValidationError("Invalid issue identifier.")

    return {"project__slug__iexact": project_slug, "digest_order": digest_order}


class Grouping(models.Model):
    """
    Grouping models the automatic part of Events should be grouped into Issues. In particular: an automatically
    calculated grouping key (from the event data, with a key role for the SDK-side fingerprint).

    They are separated out into a separate model to allow for manually merging (after the fact) multiple such groupings
    into a single issue. (such manual merging is not yet implemented, but the data-model is already prepared for it)
    """
    project = models.ForeignKey(
        "projects.Project", blank=False, null=False, on_delete=models.DO_NOTHING)

    grouping_key = models.TextField(blank=False, null=False)

    # we hash the key to make it indexable on MySQL, see https://code.djangoproject.com/ticket/2495
    grouping_key_hash = models.CharField(max_length=64, blank=False, null=True)

    grouping_mechanism = models.CharField(max_length=64, choices=GROUPING_CHOICES)

    issue = models.ForeignKey("Issue", blank=False, null=False, on_delete=models.DO_NOTHING)

    def __str__(self):
        return self.grouping_key

    class Meta:
        unique_together = [
            # principled: grouping _key_ is a _key_ for a reason (within a project). This also implies the main way of
            # looking up groupings has an appropriate index.
            ("project", "grouping_key_hash", "grouping_mechanism"),
        ]


def mark_issues_for_deletion(issue_project_pairs):
    from projects.models import Project

    # The caller first reads issue and project IDs into a list. Reusing the list means all three bulk updates affect the
    # same issues and lets us count deletions per project without another query. This costs three writes for the whole
    # list, rather than three writes per issue.
    issue_ids = [issue_id for issue_id, _project_id in issue_project_pairs]
    deletions_per_project = Counter(project_id for _issue_id, project_id in issue_project_pairs)

    Issue.objects.filter(id__in=issue_ids).update(is_deleted=True)
    Project.objects.filter(id__in=deletions_per_project).update(
        issue_count=Case(*[
            When(id=project_id, then=F("issue_count") - count)
            for project_id, count in deletions_per_project.items()
        ])
    )

    # We set grouping_key_hash to None to ensure that event digests that happen simultaneously with the delayed cleanup
    # will get their own fresh Grouping and hence Issue. This matches the behavior that would happen if Issue deletion
    # were instantaneous (i.e. it's the least surprising behavior).
    #
    # `issue=None` is explicitly _not_ part of this update, such that the actual deletion of the Groupings will be
    # picked up as part of dependency cleanup.
    Grouping.objects.filter(issue_id__in=issue_ids).update(grouping_key_hash=None)


def format_unmute_reason(unmute_metadata):
    if "mute_until" in unmute_metadata:
        d = unmute_metadata["mute_until"]
        plural_s = "" if d["nr_of_periods"] == 1 else "s"
        return f"More than { d['volume'] } events per { d['nr_of_periods'] } { d['period'] }{ plural_s } occurred, "\
               f"unmuting the issue."

    d = unmute_metadata["mute_for"]
    formatted_date = default_date_filter(d['unmute_after'], 'j M G:i')
    return f"An event was observed after the mute-deadline of { formatted_date } and the issue was unmuted."


class IssueStateManager(object):
    """basically: a namespace; with static methods that combine field-setting in a single place"""

    # NOTE I'm not so sure about the exact responsibilities of this thingie yet. In particular:
    # * save() is now done outside;  (I'm not sure it's "right", but it's shorter because we do this for each action)
    # * alerts are sent from inside.

    @staticmethod
    def resolve(issue):
        issue.is_resolved = True
        issue.is_resolved_unconditionally = True
        issue.is_resolved_by_next_release = False

        # an issue cannot be both resolved and muted; muted means "the problem persists but don't tell me about it
        # (or maybe unless some specific condition happens)" and resolved means "the problem is gone". Hence, resolving
        # an issue means unmuting it. Note that resolve-after-mute is implemented as an override but mute-after-resolve
        # is implemented as an Exception; this is because from a usage perspective saying "I don't care about this" but
        # then solving it anyway is a realistic scenario and the reverse is not.
        IssueStateManager.unmute(issue)

    @staticmethod
    def resolve_by_latest(issue):
        # NOTE: currently unused; we may soon reintroduce it though so I left it in.
        issue.is_resolved = True
        issue.is_resolved_unconditionally = False
        issue.is_resolved_by_next_release = False
        issue.add_fixed_at(issue.project.get_latest_release().version)
        IssueStateManager.unmute(issue)  # as in IssueStateManager.resolve()

    @staticmethod
    def resolve_by_release(issue, release_version):
        # release_version: str
        issue.is_resolved = True
        issue.is_resolved_unconditionally = False
        issue.is_resolved_by_next_release = False
        issue.add_fixed_at(release_version)
        IssueStateManager.unmute(issue)  # as in IssueStateManager.resolve()

    @staticmethod
    def resolve_by_next(issue):
        issue.is_resolved = True
        issue.is_resolved_unconditionally = False
        issue.is_resolved_by_next_release = True
        IssueStateManager.unmute(issue)  # as in IssueStateManager.resolve()

    @staticmethod
    def reopen(issue):
        issue.is_resolved = False
        issue.is_resolved_unconditionally = False
        issue.is_resolved_by_next_release = False  # clear related is_resolved_xxx state too

        # we don't touch `fixed_at`. The meaning of that field is "reports came in about fixes at these points in time",
        # not "it actually _was_ fixed at all of those points" and the finer differences between those 2 statements is
        # precisely what we have quite some "is_regression" logic for.

        # This also means that if you clicked resolve by mistake and immediately reopen, the stale fixed_at marker
        # remains. Full undo would be more principled but also more complex (implementation requires historic
        # information)

        # as in IssueStateManager.resolve(), but not because a reopened issue cannot be muted in principle (i.e. we
        # could mute it soon after reopening) but because when reopening an issue you're doing this from a resolved
        # state; calling unmute() here is done as an after-the-fact consistency-enforcement.
        IssueStateManager.unmute(issue)

    @staticmethod
    def mute(issue, unmute_on_volume_based_conditions="[]", unmute_after=None):
        if issue.is_resolved:
            raise IncongruentStateException("Cannot mute a resolved issue")

        issue.is_muted = True
        issue.unmute_on_volume_based_conditions = unmute_on_volume_based_conditions
        # 0 is "incorrect" but works just fine; it simply means that the first (real, but expensive) check is done
        # on-digest. However, to calculate the correct value we'd need to do that work right now, so postponing is
        # actually better. Setting to 0 is still needed to ensure the check is done when there was already a value.
        issue.next_unmute_check = 0

        if unmute_after is not None:
            issue.unmute_after = unmute_after

    @staticmethod
    def unmute(issue, triggering_event=None, unmute_metadata=None):
        if issue.is_muted:
            # we check on is_muted explicitly: it may be so that multiple unmute conditions happens simultaneously (and
            # not just in "funny configurations"). i.e. a single event could push you past more than 3 events per day or
            # 100 events per year. We don't want 2 "unmuted" alerts being sent in that case.

            issue.is_muted = False
            issue.unmute_on_volume_based_conditions = "[]"
            issue.unmute_after = None

            if triggering_event is not None:
                # (note: we can expect project to be set, because it will be None only when projects are deleted, in
                # which case no more unmuting happens)
                if issue.project.alert_on_unmute:
                    transaction.on_commit(partial(
                        send_unmute_alert.delay,
                        str(issue.id), format_unmute_reason(unmute_metadata)))

                # this is in a funny place but it's still simpler than introducing an Encoder
                if unmute_metadata is not None and "mute_for" in unmute_metadata:
                    unmute_metadata["mute_for"]["unmute_after"] = \
                        format_timestamp(unmute_metadata["mute_for"]["unmute_after"])

                # by sticking close to the point where we call send_unmute_alert.delay, we reuse any thinking about
                # avoinding double calls in edge-cases. a "coincidental advantage" of this approach is that the current
                # path is never reached via UI-based paths (because those are by definition not event-triggered); thus
                # the 2 ways of creating TurningPoints do not collide.
                TurningPoint.objects.create(
                    project_id=issue.project_id,
                    issue=issue, triggering_event=triggering_event, timestamp=triggering_event.ingested_at,
                    kind=TurningPointKind.UNMUTED, metadata=json.dumps(unmute_metadata))
                triggering_event.never_evict = True  # .save() will be called by the caller of this function

    @staticmethod
    def delete(issue):
        issue.delete_deferred()

    @staticmethod
    def get_unmute_thresholds(issue):
        unmute_vbcs = [
            VolumeBasedCondition.from_dict(vbc_s)
            for vbc_s in json.loads(issue.unmute_on_volume_based_conditions)
        ]

        # the for-loop in the below always contains 0 or 1 elements in our current UI (adding another unmute condition
        # for an already-muted issue is simply not possible) but would be robust for more elements.
        return [(vbc.period, vbc.nr_of_periods, vbc.volume) for vbc in unmute_vbcs]


class IssueQuerysetStateManager(object):
    """
    This is exaclty the same as IssueStateManager, but it works on querysets instead of single objects.
    The reason we do this as a copy/pasta (and not by just passing a queryset with a single element) is twofold:

    * the qs-approach is harder to comprehend; understanding can be aided by referring back to the simple approach
    * performance: the qs-approach may take a few queries to deal with a whole set; but when working on a single object
        a single .save() is enough.
    """

    # NOTE I'm not so sure about the exact responsibilities of this thingie yet. In particular:
    # * alerts are sent from inside.

    # NOTE: the methods in this class work on issue_qs; this allows us to do database operations over multiple objects
    # as a single query (but for our hand-made in-python operations, we obviously still just loop over the elements)

    def _resolve_at(issue_qs, release_version):
        filter_qs_for_fixed_at(issue_qs, release_version).update(
            is_resolved=True,
            is_resolved_unconditionally=False,
            is_resolved_by_next_release=False,
        )
        exclude_qs_for_fixed_at(issue_qs, release_version).update(
            is_resolved=True,
            is_resolved_unconditionally=False,
            is_resolved_by_next_release=False,
            fixed_at=Concat(F("fixed_at"), Value(release_version + "\n")),
        )

    @staticmethod
    def resolve(issue_qs):
        issue_qs.update(
            is_resolved=True,
            is_resolved_unconditionally=True,
            is_resolved_by_next_release=False,
        )

        # an issue cannot be both resolved and muted; muted means "the problem persists but don't tell me about it
        # (or maybe unless some specific condition happens)" and resolved means "the problem is gone". Hence, resolving
        # an issue means unmuting it. Note that resolve-after-mute is implemented as an override but mute-after-resolve
        # is implemented as an Exception; this is because from a usage perspective saying "I don't care about this" but
        # then solving it anyway is a realistic scenario and the reverse is not.
        IssueQuerysetStateManager.unmute(issue_qs)

    @staticmethod
    def resolve_by_latest(issue_qs):
        # NOTE: currently unused; we may soon reintroduce it though so I left it in.
        # However, since it's unused, I'm not going to fix the line below, which doesn't work because issue.project is
        # not available; (we might consider adding the restriction that project is always the same; or pass it in
        # explicitly)

        raise NotImplementedError("resolve_by_latest is not implemented - see comments above")
        # the solution is along these lines, but with the project passed in:
        # IssueQuerysetStateManager._resolve_at(issue_qs, issue.project.get_latest_release().version)
        # IssueQuerysetStateManager.unmute(issue_qs)  # as in IssueQuerysetStateManager.resolve()

    @staticmethod
    def resolve_by_release(issue_qs, release_version):
        # release_version: str
        IssueQuerysetStateManager._resolve_at(issue_qs, release_version)
        IssueQuerysetStateManager.unmute(issue_qs)  # as in IssueQuerysetStateManager.resolve()

    @staticmethod
    def resolve_by_next(issue_qs):
        issue_qs.update(
            is_resolved=True,
            is_resolved_unconditionally=False,
            is_resolved_by_next_release=True,
        )

        IssueQuerysetStateManager.unmute(issue_qs)  # as in IssueQuerysetStateManager.resolve()

    @staticmethod
    def reopen(issue_qs):
        # Currently unused by the UI; implemented for completeness because generic issue-action plumbing supports it.
        issue_qs.update(
            is_resolved=False,
            is_resolved_unconditionally=False,
            is_resolved_by_next_release=False,
        )
        IssueQuerysetStateManager.unmute(issue_qs)

    @staticmethod
    def mute(issue_qs, unmute_on_volume_based_conditions="[]", unmute_after=None):
        if issue_qs.filter(is_resolved=True).exists():
            # we might remove this check for performance reasons later (it's more expensive here than in the non-bulk
            # case because we have to do a query to check for it). For now we leave it in to avoid surprises while we're
            # still heavily in development.
            raise IncongruentStateException("Cannot mute a resolved issue")

        issue_qs.update(
            is_muted=True,
            unmute_on_volume_based_conditions=unmute_on_volume_based_conditions,
            next_unmute_check=0,
        )

        if unmute_after is not None:
            issue_qs.update(unmute_after=unmute_after)

    @staticmethod
    def unmute(issue_qs, triggering_event=None):
        issue_qs.update(
            is_muted=False,
            unmute_on_volume_based_conditions="[]",
            unmute_after=None,
        )

        assert_(triggering_event is None, "this method can only be called from the UI, i.e. user-not-event-triggered")
        # for the rest of this method there's no fancy queryset based stuff (we don't actually do updates on the DB)
        # we resist the temptation to add filter(is_muted=True) in the below because that would actually add a query
        # (for this remark to be true triggering_event must be None, which is asserted for in the above)
        for issue in issue_qs:
            IssueStateManager.unmute(issue, triggering_event)

    @staticmethod
    def delete(issue_qs):
        issue_project_pairs = list(issue_qs.values_list("id", "project_id"))
        if not issue_project_pairs:
            return

        mark_issues_for_deletion(issue_project_pairs)
        for issue_id, project_id in issue_project_pairs:
            delay_on_commit(delete_issue_deps, str(project_id), str(issue_id))


def is_valid_issue_action(action, issue):
    """We take the 'strict' approach of complaining even when the action is simply a no-op, because you're already in
    the desired state."""

    if action == "delete":
        # any type of issue can be deleted
        return True

    if action == "reopen":
        # reopen is the only one that requires the issue to currently be resolved
        return issue.is_resolved

    if issue.is_resolved:
        # any other action is illegal on resolved issues
        return False

    if action.startswith("mute"):
        if issue.is_muted:
            return False

        # TODO muting with a VBC that is already met should be invalid. See 'Exception("The unmute condition is already'

    elif action == "unmute":
        if not issue.is_muted:
            return False

    return True


def q_for_invalid_issue_action(action):
    """returns a Q obj of issues for which the action is not valid."""

    if action == "delete":
        # delete is always valid, so we don't want any issues to be returned, https://stackoverflow.com/a/39001190
        return Q(pk__in=[])

    if action == "reopen":
        # reopen is the only one that requires the issue to currently be resolved
        return Q(is_resolved=False)

    illegal_conditions = Q(is_resolved=True)  # any other action is illegal on resolved issues

    if action.startswith("mute"):
        illegal_conditions = illegal_conditions | Q(is_muted=True)

    elif action == "unmute":
        illegal_conditions = illegal_conditions | Q(is_muted=False)

    return illegal_conditions


def make_issue_history(issue_or_qs, action, user):
    if action == "delete":
        return  # we're about to delete the issue, so no history is needed (nor possible)

    elif action == "resolve":
        kind = TurningPointKind.RESOLVED
    elif action.startswith("resolved"):
        kind = TurningPointKind.RESOLVED
    elif action.startswith("mute"):
        kind = TurningPointKind.MUTED
    elif action == "unmute":
        kind = TurningPointKind.UNMUTED
    elif action == "reopen":
        kind = TurningPointKind.REOPENED
    else:
        raise ValueError(f"unknown action: {action}")

    if action.startswith("mute_for:"):
        mute_for_params = action.split(":", 1)[1]
        period_name, nr_of_periods, _ = mute_for_params.split(",")
        unmute_after = add_periods_to_datetime(timezone.now(), int(nr_of_periods), period_name)
        metadata = {"mute_for": {
            "period_name": period_name, "nr_of_periods": int(nr_of_periods),
            "unmute_after": format_timestamp(unmute_after)}}

    elif action.startswith("mute_until:"):
        mute_for_params = action.split(":", 1)[1]
        period_name, nr_of_periods, gte_threshold = mute_for_params.split(",")
        metadata = {"mute_until": {
            "period_name": period_name, "nr_of_periods": int(nr_of_periods), "gte_threshold": gte_threshold}}

    elif action == "mute":
        metadata = {"mute_unconditionally": True}

    elif action.startswith("resolved_release:"):
        release_version = action.split(":", 1)[1]
        metadata = {"resolved_release": release_version}
    elif action == "resolved_next":
        metadata = {"resolve_by_next": True}
    elif action == "resolve":
        metadata = {"resolved_unconditionally": True}
    else:
        metadata = {}

    now = timezone.now()
    if isinstance(issue_or_qs, Issue):
        TurningPoint.objects.create(
            project=issue_or_qs.project,
            issue=issue_or_qs, kind=kind, user=user, metadata=json.dumps(metadata), timestamp=now)
    else:
        TurningPoint.objects.bulk_create([
            TurningPoint(
                project_id=issue.project_id, issue=issue, kind=kind, user=user, metadata=json.dumps(metadata),
                timestamp=now)
            for issue in issue_or_qs
        ])


def apply_issue_action(manager, issue_or_qs, action, user):
    make_issue_history(issue_or_qs, action, user)

    if action == "resolve":
        manager.resolve(issue_or_qs)
    elif action.startswith("resolved_release:"):
        release_version = action.split(":", 1)[1]
        manager.resolve_by_release(issue_or_qs, release_version)
    elif action == "resolved_next":
        manager.resolve_by_next(issue_or_qs)
    elif action == "reopen":
        manager.reopen(issue_or_qs)
    elif action == "mute":
        manager.mute(issue_or_qs)
    elif action.startswith("mute_for:"):
        mute_for_params = action.split(":", 1)[1]
        period_name, nr_of_periods, _ = mute_for_params.split(",")
        unmute_after = add_periods_to_datetime(timezone.now(), int(nr_of_periods), period_name)
        manager.mute(issue_or_qs, unmute_after=unmute_after)

    elif action.startswith("mute_until:"):
        mute_for_params = action.split(":", 1)[1]
        period_name, nr_of_periods, gte_threshold = mute_for_params.split(",")

        manager.mute(issue_or_qs, unmute_on_volume_based_conditions=json.dumps([{
            "period": period_name,
            "nr_of_periods": int(nr_of_periods),
            "volume": int(gte_threshold),
        }]))
    elif action == "unmute":
        manager.unmute(issue_or_qs)
    elif action == "delete":
        manager.delete(issue_or_qs)


class TurningPointKind(models.IntegerChoices):
    # The language of the kinds reflects a historic view of the system, e.g. "first seen" as opposed to "new issue"; an
    # alternative take (which is more consistent with the language used elsewhere" is a more "active" language.
    FIRST_SEEN = 1, _("First seen")
    RESOLVED = 2, _("Resolved")
    MUTED = 3, _("Muted")
    REGRESSED = 4, _("Marked as regressed")
    UNMUTED = 5, _("Unmuted")
    REOPENED = 6, _("Reopened")

    NEXT_MATERIALIZED = 10, _("Release info added")

    # ASSGINED = 10, "Assigned to user"   # perhaps later
    MANUAL_ANNOTATION = 100, _("Manual annotation")


class TurningPoint(models.Model):
    """A TurningPoint models a point in time in the history of an issue."""
    # basically: an Event, but that name was already taken in our system :-) alternative names I considered:
    # "milestone", "state_change", "transition", "annotation", "episode"

    project = models.ForeignKey("projects.Project", blank=False, null=False, on_delete=models.DO_NOTHING)
    issue = models.ForeignKey("Issue", blank=False, null=False, on_delete=models.DO_NOTHING)
    triggering_event = models.ForeignKey("events.Event", blank=True, null=True, on_delete=models.DO_NOTHING)

    # null: the system-user
    user = models.ForeignKey(settings.AUTH_USER_MODEL, blank=True, null=True, on_delete=models.SET_NULL)
    timestamp = models.DateTimeField(blank=False, null=False)  # this info is also in the event, but event is nullable
    kind = models.IntegerField(blank=False, null=False, choices=TurningPointKind.choices)
    metadata = models.TextField(blank=False, null=False, default="{}")  # json string
    comment = models.TextField(blank=True, null=False, default="")

    class Meta:
        # by ordering on "-id" we order things that happen in a single ingestion in the order in which they happened.
        # (in particular: NEXT_MATERIALIZED followed by REGRESSED is a common pattern)
        ordering = ["-timestamp", "-id"]
        indexes = [
            models.Index(fields=["timestamp"]),
        ]

    def parsed_metadata(self):
        if not hasattr(self, "_parsed_metadata"):
            self._parsed_metadata = json.loads(self.metadata)
            # rather than doing some magic using an encoder/decoder we just convert the single value that we know to be
            # time
            if "mute_for" in self._parsed_metadata and "unmute_after" in self._parsed_metadata["mute_for"]:
                self._parsed_metadata["mute_for"]["unmute_after"] = \
                    parse_timestamp(self._parsed_metadata["mute_for"]["unmute_after"])
        return self._parsed_metadata