diff --git a/CHANGELOG.md b/CHANGELOG.md
index bbd9fcb..e89432c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -68,6 +68,18 @@ version last got one.
hidden from that view only, not deleted - reviewers and reports still see it, same as
before. Pending submissions in the same "Remove selected" action are still deleted
outright, as they always were. Migration 0038 adds `FitSubmission.hidden_at`.
+- Admins get a Notifications card on the Settings page with a per-type on/off switch for
+ every Alliance Auth notification Fit Check emits: reviewer pings on new submissions,
+ the reviewer digest, pilot approve/reject decisions (covers both a reviewer's decision
+ and a doctrine's "approved by rule" auto-approval), and pilot stale-fit notices.
+ Pilots can also mute every Fit Check notification for their own account from a toggle
+ on the Pilot Fittings page - a muted user simply never gets a Notification row created,
+ which also silences any Discord relay (e.g. aa-discordnotify) since those relay
+ existing Notification rows. The legacy `FITCHECK_NOTIFY_REVIEWERS`,
+ `FITCHECK_REVIEWER_DIGEST`, and `FITCHECK_NOTIFY_PILOTS_STALE` Django settings now only
+ seed the new toggles' defaults on an install's first upgrade; after that, the
+ Notifications page is authoritative. Migration 0040 adds `NotificationSettings` and
+ `UserNotificationPreference`.
### Changed
- Fit detail: doctrine categories are now shown once, in a combined row below the
diff --git a/README.md b/README.md
index 2dea615..f050205 100644
--- a/README.md
+++ b/README.md
@@ -390,9 +390,9 @@ works normally without it — the filter simply isn't offered.
| Setting | Default | Purpose |
|---|---|---|
| `FITCHECK_SDE_SOURCE_URL` | Official CCP JSONL bundle | Static-data archive URL |
-| `FITCHECK_NOTIFY_REVIEWERS` | `True` | Notify reviewers on new submissions |
-| `FITCHECK_REVIEWER_DIGEST` | `False` | Periodic digest instead of per-submission pings (schedule `fitcheck.tasks.send_review_digest`) |
-| `FITCHECK_NOTIFY_PILOTS_STALE` | `True` | When "Recheck Stale" re-grades stale submissions, notify the affected pilots (with an old-to-new module diff when the fit's BOM changed) and warn approved-submission holders that the fit moved on |
+| `FITCHECK_NOTIFY_REVIEWERS` | `True` | **First-install default only** (see below) — notify reviewers on new submissions |
+| `FITCHECK_REVIEWER_DIGEST` | `False` | **First-install default only** (see below) — periodic digest instead of per-submission pings (schedule `fitcheck.tasks.send_review_digest`) |
+| `FITCHECK_NOTIFY_PILOTS_STALE` | `True` | **First-install default only** (see below) — when a fit/policy change re-grades a pending submission, notify the pilot (with an old-to-new module diff when the BOM changed) and warn approved-submission holders that the fit moved on |
| `FITCHECK_ESI_CONTACT` | `ESI_USER_CONTACT_EMAIL` | Contact email in the ESI User-Agent header |
| `FITCHECK_ASSET_SOURCE` | `auto` | Where pilot/member ship inventory comes from: `auto` (corptools cache when available, else live ESI), `esi`, or `corptools` |
| `FITCHECK_STRUCTURE_CACHE_TTL` | `86400` | Seconds before a cached player-structure (Citadel) name is re-resolved by `fitcheck.tasks.refresh_structure_names` (default 24h). The Member Inventory scan reads these names locally and never calls ESI for them. |
@@ -414,6 +414,20 @@ corptools is absent or the asset source is forced to `esi`, the live-ESI operati
to the configured budget, and an upfront notice is shown on the Member Inventory page. Each
field explains the impact of raising it.
+Notifications are likewise managed in-app, on the **Notification Settings** page (Settings
+tab, admin-only, `manage_policies`): one on/off switch per notification type — reviewer
+pings on new submissions, the reviewer digest, pilot approve/reject decisions (covers both
+a reviewer's decision and a doctrine's "approved by rule" auto-approval), and pilot
+stale-fit notices. **The three `FITCHECK_NOTIFY_*`/`FITCHECK_REVIEWER_DIGEST` Django
+settings above only seed these toggles' defaults the first time the settings page (or any
+notification) is touched after upgrading** — an admin who edits them in `local.py` on an
+already-running install will see no effect; use the Notification Settings page instead.
+Independently, any pilot can mute every Fit Check notification for their own account from a
+toggle on the Pilot Fittings page. Muting is fitcheck-wide: a muted user's notifications are
+never created at all (not just hidden), which also suppresses any Discord relay of them
+(e.g. via aa-discordnotify), since those relay existing Notification rows rather than
+intercepting the event.
+
---
## Integration (Python API)
diff --git a/fitcheck/app_settings.py b/fitcheck/app_settings.py
index 8a5b78d..fe2d504 100644
--- a/fitcheck/app_settings.py
+++ b/fitcheck/app_settings.py
@@ -20,6 +20,11 @@
# Optional URL for the mutaplasmid dataset (dynamicItemAttributes). Empty = use bundled default.
FITCHECK_SDE_DYNAMIC_ITEMS_URL = clean_setting("FITCHECK_SDE_DYNAMIC_ITEMS_URL", "")
+# NOTE: the three FITCHECK_NOTIFY_*/FITCHECK_REVIEWER_DIGEST settings below are
+# now first-install defaults ONLY. They seed models.NotificationSettings the
+# first time it's read; after that, the in-app Settings -> Notifications page
+# is authoritative and editing these here has no further effect.
+
# Notify users with review permission when a new submission arrives.
FITCHECK_NOTIFY_REVIEWERS = clean_setting("FITCHECK_NOTIFY_REVIEWERS", True)
diff --git a/fitcheck/forms.py b/fitcheck/forms.py
index c335027..fdd44d3 100644
--- a/fitcheck/forms.py
+++ b/fitcheck/forms.py
@@ -21,6 +21,7 @@
DoctrineFit,
DoctrineFitItem,
EnforcementSettings,
+ NotificationSettings,
ScanParameters,
SdeType,
)
@@ -602,3 +603,57 @@ class Meta:
"fewer clicks but heavier queries and longer scrolls."
),
}
+
+
+class NotificationSettingsForm(forms.ModelForm):
+ """Per-type on/off switches for the Alliance Auth notifications Fit Check
+ emits. Turning a type off skips only the notify() call for it - the
+ underlying action (grading, approval, etc.) still happens."""
+
+ class Meta:
+ model = NotificationSettings
+ fields = [
+ "notify_reviewers_new_submission",
+ "reviewer_digest",
+ "notify_member_decision",
+ "notify_pilots_stale",
+ ]
+ widgets = {
+ name: forms.CheckboxInput(attrs={"class": "form-check-input"})
+ for name in (
+ "notify_reviewers_new_submission",
+ "reviewer_digest",
+ "notify_member_decision",
+ "notify_pilots_stale",
+ )
+ }
+ labels = {
+ "notify_reviewers_new_submission": _("Notify reviewers of new submissions"),
+ "reviewer_digest": _("Reviewer digest (periodic summary instead)"),
+ "notify_member_decision": _("Notify pilots of approve/reject decisions"),
+ "notify_pilots_stale": _("Notify pilots when a fitting standard changes"),
+ }
+ help_texts = {
+ "notify_reviewers_new_submission": _(
+ "Ping everyone with review authority as soon as a member "
+ "submits a fit for review. Has no effect while the digest "
+ "below is on."
+ ),
+ "reviewer_digest": _(
+ "Send reviewers a periodic summary of the pending queue "
+ "instead of a ping per submission. Needs "
+ "fitcheck.tasks.send_review_digest scheduled via "
+ "CELERYBEAT_SCHEDULE to actually run."
+ ),
+ "notify_member_decision": _(
+ "Tell a pilot when their submission is approved or rejected - "
+ "by a reviewer, or automatically when a doctrine auto-approves "
+ "it."
+ ),
+ "notify_pilots_stale": _(
+ "Tell a pilot when a fitting-standard change re-grades their "
+ "pending submission (with a module diff when the BOM "
+ "changed), and warn holders of an already-approved submission "
+ "that the fit has moved on."
+ ),
+ }
diff --git a/fitcheck/migrations/0040_notificationsettings_usernotificationpreference.py b/fitcheck/migrations/0040_notificationsettings_usernotificationpreference.py
new file mode 100644
index 0000000..35e0f0e
--- /dev/null
+++ b/fitcheck/migrations/0040_notificationsettings_usernotificationpreference.py
@@ -0,0 +1,44 @@
+# Generated by Django 5.2.15 on 2026-07-08 18:18
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('fitcheck', '0039_doctrine_auto_approve_and_more'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='NotificationSettings',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('notify_reviewers_new_submission', models.BooleanField(default=True, help_text='Ping everyone with review authority as soon as a member submits a fit for review. Has no effect while Reviewer digest (below) is on.')),
+ ('reviewer_digest', models.BooleanField(default=False, help_text='Send a periodic summary of the pending queue instead of a ping per submission. Suppresses the per-submission ping above; requires fitcheck.tasks.send_review_digest to be scheduled via CELERYBEAT_SCHEDULE to actually run.')),
+ ('notify_member_decision', models.BooleanField(default=True, help_text="Tell a pilot their submission was approved or rejected - covers both a human reviewer's decision and a doctrine's automatic 'approved by rule' decision.")),
+ ('notify_pilots_stale', models.BooleanField(default=True, help_text='Tell a pilot when a fitting-standard change re-grades their pending submission (with an old-to-new module diff when the BOM changed), and warn holders of an already-approved submission that the fit has moved on since their approval.')),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ 'verbose_name': 'Notification settings',
+ 'verbose_name_plural': 'Notification settings',
+ },
+ ),
+ migrations.CreateModel(
+ name='UserNotificationPreference',
+ fields=[
+ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('mute_all', models.BooleanField(default=False, help_text='Suppress every Fit Check notification for this account.')),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='fitcheck_notification_preference', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'verbose_name': 'Notification preference',
+ 'verbose_name_plural': 'Notification preferences',
+ },
+ ),
+ ]
diff --git a/fitcheck/models/__init__.py b/fitcheck/models/__init__.py
index 0816637..3e3e223 100644
--- a/fitcheck/models/__init__.py
+++ b/fitcheck/models/__init__.py
@@ -15,7 +15,9 @@
from .reporting import ComplianceSnapshot # noqa: F401
from .settings import ( # noqa: F401
EnforcementSettings,
+ NotificationSettings,
ScanParameters,
+ UserNotificationPreference,
VerificationMode,
)
from .sde import ( # noqa: F401
diff --git a/fitcheck/models/settings.py b/fitcheck/models/settings.py
index 12bcb9e..7be9611 100644
--- a/fitcheck/models/settings.py
+++ b/fitcheck/models/settings.py
@@ -1,3 +1,4 @@
+from django.conf import settings as django_settings
from django.db import models
from django.utils.translation import gettext_lazy as _
@@ -95,3 +96,122 @@ def current(cls) -> "ScanParameters":
"""The single parameters row, created with defaults on first access."""
obj, _created = cls.objects.get_or_create(pk=1)
return obj
+
+
+class NotificationSettings(models.Model):
+ """Singleton of admin-tunable per-type on/off switches for the Alliance Auth
+ notifications Fit Check emits from ``fitcheck.tasks`` (Settings ->
+ Notifications). Turning a type off does not delete or skip anything except
+ the ``allianceauth.notifications.notify()`` call for it - no Notification
+ row is created, so any relay app (e.g. aa-discordnotify) that reads
+ Notification rows is silenced too.
+
+ On first-ever access (no row yet) the four fields are seeded from the
+ legacy ``FITCHECK_NOTIFY_REVIEWERS`` / ``FITCHECK_REVIEWER_DIGEST`` /
+ ``FITCHECK_NOTIFY_PILOTS_STALE`` Django settings, so an existing install's
+ configured behaviour survives the upgrade unchanged. After that first
+ access this row is authoritative - editing those legacy settings in
+ ``local.py`` has no further effect; use the Notification Settings page.
+ """
+
+ notify_reviewers_new_submission = models.BooleanField(
+ default=True,
+ help_text=_(
+ "Ping everyone with review authority as soon as a member submits a "
+ "fit for review. Has no effect while Reviewer digest (below) is on."
+ ),
+ )
+ reviewer_digest = models.BooleanField(
+ default=False,
+ help_text=_(
+ "Send a periodic summary of the pending queue instead of a ping "
+ "per submission. Suppresses the per-submission ping above; "
+ "requires fitcheck.tasks.send_review_digest to be scheduled via "
+ "CELERYBEAT_SCHEDULE to actually run."
+ ),
+ )
+ notify_member_decision = models.BooleanField(
+ default=True,
+ help_text=_(
+ "Tell a pilot their submission was approved or rejected - covers "
+ "both a human reviewer's decision and a doctrine's automatic "
+ "'approved by rule' decision."
+ ),
+ )
+ notify_pilots_stale = models.BooleanField(
+ default=True,
+ help_text=_(
+ "Tell a pilot when a fitting-standard change re-grades their "
+ "pending submission (with an old-to-new module diff when the BOM "
+ "changed), and warn holders of an already-approved submission "
+ "that the fit has moved on since their approval."
+ ),
+ )
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ verbose_name = "Notification settings"
+ verbose_name_plural = "Notification settings"
+
+ def __str__(self) -> str:
+ return "Fit Check notification settings"
+
+ def save(self, *args, **kwargs):
+ self.pk = 1 # enforce a single row
+ super().save(*args, **kwargs)
+
+ @classmethod
+ def current(cls) -> "NotificationSettings":
+ """The single settings row. On first-ever access, seeds the four
+ fields from the legacy FITCHECK_NOTIFY_*/FITCHECK_REVIEWER_DIGEST
+ Django settings (imported here, not at module scope, so tests can
+ patch ``fitcheck.app_settings`` and have it take effect on this call)."""
+ from ..app_settings import (
+ FITCHECK_NOTIFY_PILOTS_STALE,
+ FITCHECK_NOTIFY_REVIEWERS,
+ FITCHECK_REVIEWER_DIGEST,
+ )
+
+ obj, _created = cls.objects.get_or_create(
+ pk=1,
+ defaults={
+ "notify_reviewers_new_submission": FITCHECK_NOTIFY_REVIEWERS,
+ "reviewer_digest": FITCHECK_REVIEWER_DIGEST,
+ "notify_pilots_stale": FITCHECK_NOTIFY_PILOTS_STALE,
+ },
+ )
+ return obj
+
+
+class UserNotificationPreference(models.Model):
+ """Per-user opt-out of every Fit Check notification. A muted user simply
+ never has a Notification row created for them, so muting is fitcheck-wide
+ (it also silences a Discord relay app, since those relay existing
+ Notification rows rather than intercepting the event)."""
+
+ user = models.OneToOneField(
+ django_settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="fitcheck_notification_preference",
+ )
+ mute_all = models.BooleanField(
+ default=False,
+ help_text=_("Suppress every Fit Check notification for this account."),
+ )
+ updated_at = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ verbose_name = "Notification preference"
+ verbose_name_plural = "Notification preferences"
+
+ def __str__(self) -> str:
+ return f"{self.user}: {'muted' if self.mute_all else 'unmuted'}"
+
+ @classmethod
+ def is_muted(cls, user) -> bool:
+ """True when ``user`` has muted Fit Check notifications. Never raises
+ when no preference row exists yet (the common case) or when ``user``
+ is None/anonymous."""
+ if not getattr(user, "pk", None):
+ return False
+ return cls.objects.filter(user=user, mute_all=True).exists()
diff --git a/fitcheck/tasks.py b/fitcheck/tasks.py
index 414a03b..e0de2ee 100644
--- a/fitcheck/tasks.py
+++ b/fitcheck/tasks.py
@@ -6,12 +6,7 @@
from allianceauth.notifications import notify
-from .app_settings import (
- FITCHECK_NOTIFY_PILOTS_STALE,
- FITCHECK_NOTIFY_REVIEWERS,
- FITCHECK_REVIEWER_DIGEST,
-)
-from .models import DoctrineFit, FitSubmission
+from .models import DoctrineFit, FitSubmission, NotificationSettings, UserNotificationPreference
def _reviewers():
@@ -111,6 +106,8 @@ def _notify_approved_stale(fit: DoctrineFit) -> None:
if not submission.is_stale or submission.user_id in seen_users:
continue
seen_users.add(submission.user_id)
+ if UserNotificationPreference.is_muted(submission.user):
+ continue
# Keyed on every ladder the submission's currency depends on, so a
# repeated Recheck Stale run stays silent but any NEW change (global
# or this submission's own policy ladder) re-arms the notification.
@@ -156,13 +153,17 @@ def recheck_pending_submissions(fit_id: int):
submissions and tell the affected pilots what happened (with an old->new
module diff when the BOM changed). Holders of approved submissions that
predate the change are warned once per fit version - their decision is
- never re-graded."""
+ never re-graded. Every notification here (pending re-grade, plain
+ verdict-changed fallback, approved-holder warning) is one logical event -
+ "a fit/policy change touched your submission" - gated by the single
+ ``notify_pilots_stale`` toggle."""
from .services.check_runner import recheck_submission
from .services.fit_diff import diff_for_submission
fit = DoctrineFit.objects.filter(pk=fit_id).first()
if fit is None:
return
+ notify_stale = NotificationSettings.current().notify_pilots_stale
pending = (
fit.submissions.pending().with_staleness().select_related("doctrine_fit", "user")
)
@@ -172,7 +173,9 @@ def recheck_pending_submissions(fit_id: int):
# Resolve the diff before the re-check resets fit_version to current.
diff = diff_for_submission(submission) if was_stale else None
recheck_submission(submission)
- if was_stale and FITCHECK_NOTIFY_PILOTS_STALE:
+ if UserNotificationPreference.is_muted(submission.user):
+ continue
+ if was_stale and notify_stale:
body = gettext(
"The fitting standard '%(fit)s' was updated and your pending "
"submission #%(id)s was re-graded against the new version. "
@@ -190,7 +193,7 @@ def recheck_pending_submissions(fit_id: int):
message=body,
level="warning",
)
- elif submission.verdict != old_verdict:
+ elif notify_stale and submission.verdict != old_verdict:
notify(
submission.user,
title=gettext("Fit Check: verdict changed for %(fit)s") % {"fit": fit.name},
@@ -205,13 +208,14 @@ def recheck_pending_submissions(fit_id: int):
},
level="warning",
)
- if FITCHECK_NOTIFY_PILOTS_STALE:
+ if notify_stale:
_notify_approved_stale(fit)
@shared_task(time_limit=300)
def notify_reviewers_new_submission(submission_id: int):
- if not FITCHECK_NOTIFY_REVIEWERS or FITCHECK_REVIEWER_DIGEST:
+ settings = NotificationSettings.current()
+ if not settings.notify_reviewers_new_submission or settings.reviewer_digest:
return
submission = (
FitSubmission.objects.filter(pk=submission_id)
@@ -226,6 +230,8 @@ def notify_reviewers_new_submission(submission_id: int):
if submission.status != FitSubmission.Status.PENDING:
return
for reviewer in _reviewers():
+ if UserNotificationPreference.is_muted(reviewer):
+ continue
notify(
reviewer,
title=gettext("Fit Check: new submission to review"),
@@ -242,7 +248,10 @@ def notify_reviewers_new_submission(submission_id: int):
@shared_task(time_limit=300)
def send_review_digest():
"""Periodic summary of the pending queue for reviewers. Schedule this via
- CELERYBEAT_SCHEDULE when FITCHECK_REVIEWER_DIGEST is enabled."""
+ CELERYBEAT_SCHEDULE, and turn the "Reviewer digest" toggle on in Settings ->
+ Notifications - this task is a no-op while it's off, even if scheduled."""
+ if not NotificationSettings.current().reviewer_digest:
+ return
pending = FitSubmission.objects.pending().select_related("doctrine_fit")
total = pending.count()
if not total:
@@ -253,6 +262,8 @@ def send_review_digest():
by_fit[key] = by_fit.get(key, 0) + 1
breakdown = "\n".join(f"- {name}: {count}" for name, count in sorted(by_fit.items()))
for reviewer in _reviewers():
+ if UserNotificationPreference.is_muted(reviewer):
+ continue
notify(
reviewer,
title=ngettext(
@@ -297,6 +308,11 @@ def take_compliance_snapshots():
@shared_task(time_limit=300)
def notify_member_decision(submission_id: int):
+ """Tell a pilot their submission was approved or rejected - covers both a
+ human reviewer's decision and a doctrine's automatic "approved by rule"
+ decision (one logical event, one `notify_member_decision` toggle)."""
+ if not NotificationSettings.current().notify_member_decision:
+ return
submission = (
FitSubmission.objects.filter(pk=submission_id)
.select_related("doctrine_fit", "user", "reviewed_by")
@@ -304,6 +320,8 @@ def notify_member_decision(submission_id: int):
)
if submission is None:
return
+ if UserNotificationPreference.is_muted(submission.user):
+ return
approved = submission.status == FitSubmission.Status.APPROVED
# Approved with no reviewer but a decision time = a rule auto-approval.
auto_approved = (
diff --git a/fitcheck/templates/fitcheck/pilot_fittings.html b/fitcheck/templates/fitcheck/pilot_fittings.html
index 1beb36f..be4ddf4 100644
--- a/fitcheck/templates/fitcheck/pilot_fittings.html
+++ b/fitcheck/templates/fitcheck/pilot_fittings.html
@@ -24,7 +24,16 @@
{% translate "Pick real ships from your hangars - across every character on this account - and validate them against the published standards. Only you can see your results here." %}
-
+
+
{% translate "My Ships" %}
diff --git a/fitcheck/templates/fitcheck/settings/home.html b/fitcheck/templates/fitcheck/settings/home.html
index 7505427..be8da7d 100644
--- a/fitcheck/templates/fitcheck/settings/home.html
+++ b/fitcheck/templates/fitcheck/settings/home.html
@@ -92,6 +92,19 @@
{% translate "Scan & re
+
+
+
+
{% translate "Notifications" %}
+
+ {% translate "Per-type on/off switches for reviewer pings/digest and pilot decision/stale notices - replaces the old FITCHECK_NOTIFY_* Django settings." %}
+
+
+ {% translate "Edit notifications" %}
+
+
+
+
{% endif %}
diff --git a/fitcheck/templates/fitcheck/settings/notifications.html b/fitcheck/templates/fitcheck/settings/notifications.html
new file mode 100644
index 0000000..3b5e069
--- /dev/null
+++ b/fitcheck/templates/fitcheck/settings/notifications.html
@@ -0,0 +1,58 @@
+{% extends "fitcheck/base.html" %}
+
+{% load i18n %}
+
+{% block fitcheck_content %}
+
+
+
+
+
+
+ {% translate "Turn individual Alliance Auth notifications on or off. Turning a type off skips only the notification for it - the underlying grading/approval still happens as normal. A pilot can also mute every Fit Check notification for their own account from the Pilot Fittings page." %}
+
+
+
+
+
+
+{% endblock %}
diff --git a/fitcheck/tests/test_m3_features.py b/fitcheck/tests/test_m3_features.py
index 3b92a70..ed26861 100644
--- a/fitcheck/tests/test_m3_features.py
+++ b/fitcheck/tests/test_m3_features.py
@@ -1,12 +1,10 @@
-from unittest.mock import patch
-
from django.test import TestCase
from django.urls import reverse
from allianceauth.notifications.models import Notification
from ..constants import Section
-from ..models import FitSubmission
+from ..models import FitSubmission, NotificationSettings
from ..services.eft_parser import parse_eft
from ..services.check_runner import submit_fit
from ..tasks import send_review_digest
@@ -111,7 +109,13 @@ def _submit(self):
self.member, self.fit, parse_eft("[Harbinger, X]\nHeat Sink II\n")
)
+ def _enable_digest(self):
+ settings_obj = NotificationSettings.current()
+ settings_obj.reviewer_digest = True
+ settings_obj.save()
+
def test_digest_summarizes_pending_queue(self):
+ self._enable_digest()
self._submit()
self._submit()
Notification.objects.all().delete()
@@ -121,16 +125,23 @@ def test_digest_summarizes_pending_queue(self):
self.assertIn(str(self.fit), notification.message)
def test_digest_silent_when_queue_empty(self):
+ self._enable_digest()
+ send_review_digest()
+ self.assertFalse(Notification.objects.filter(user=self.reviewer).exists())
+
+ def test_digest_off_sends_nothing(self):
+ self._submit()
+ Notification.objects.all().delete()
send_review_digest()
self.assertFalse(Notification.objects.filter(user=self.reviewer).exists())
def test_digest_mode_suppresses_immediate_notifications(self):
from ..tasks import notify_reviewers_new_submission
+ self._enable_digest()
submission = self._submit()
Notification.objects.all().delete()
- with patch("fitcheck.tasks.FITCHECK_REVIEWER_DIGEST", True):
- notify_reviewers_new_submission(submission.pk)
+ notify_reviewers_new_submission(submission.pk)
self.assertFalse(Notification.objects.filter(user=self.reviewer).exists())
diff --git a/fitcheck/tests/test_notification_settings.py b/fitcheck/tests/test_notification_settings.py
new file mode 100644
index 0000000..49d334e
--- /dev/null
+++ b/fitcheck/tests/test_notification_settings.py
@@ -0,0 +1,406 @@
+"""Tests for admin per-type notification toggles (Settings -> Notifications)
+and the per-user "mute all" preference.
+
+Covers: the NotificationSettings singleton (incl. legacy-setting seeding on
+first access), UserNotificationPreference.is_muted, the settings page, the
+pilot-side mute toggle endpoint, and - driving the real tasks - that each
+toggle actually gates its producer's Notification rows and that a muted user
+is skipped while others in the same loop are unaffected.
+"""
+
+from unittest.mock import patch
+
+from django.contrib.auth.models import Permission, User
+from django.test import TestCase
+from django.urls import reverse
+from django.utils import timezone
+
+from allianceauth.notifications.models import Notification
+
+from ..constants import Section
+from ..models import (
+ Doctrine,
+ FitSubmission,
+ NotificationSettings,
+ UserNotificationPreference,
+)
+from ..services.check_runner import submit_fit
+from ..services.eft_parser import parse_eft
+from ..tasks import (
+ notify_member_decision,
+ notify_reviewers_new_submission,
+ recheck_pending_submissions,
+ send_review_digest,
+)
+from .testdata.factories import add_item, create_doctrine, create_fit, create_user
+from .testdata.sde_fixtures import T, create_sde_testdata
+
+# 3x Heat Sink II == the doctrine requirement -> Compliant.
+COMPLIANT_EFT = "[Harbinger, X]\nHeat Sink II\nHeat Sink II\nHeat Sink II\n"
+
+
+def _grant(user, codename):
+ user.user_permissions.add(
+ Permission.objects.get(content_type__app_label="fitcheck", codename=codename)
+ )
+ return User.objects.get(pk=user.pk) # refresh perm cache
+
+
+class NotificationSettingsModelTests(TestCase):
+ def test_current_creates_singleton_with_defaults(self):
+ settings_obj = NotificationSettings.current()
+ self.assertEqual(settings_obj.pk, 1)
+ self.assertTrue(settings_obj.notify_reviewers_new_submission)
+ self.assertFalse(settings_obj.reviewer_digest)
+ self.assertTrue(settings_obj.notify_member_decision)
+ self.assertTrue(settings_obj.notify_pilots_stale)
+
+ def test_save_always_targets_the_single_row(self):
+ NotificationSettings(notify_reviewers_new_submission=False).save()
+ NotificationSettings(notify_reviewers_new_submission=True).save()
+ self.assertEqual(NotificationSettings.objects.count(), 1)
+ self.assertTrue(NotificationSettings.current().notify_reviewers_new_submission)
+
+ @patch("fitcheck.app_settings.FITCHECK_NOTIFY_REVIEWERS", False)
+ @patch("fitcheck.app_settings.FITCHECK_REVIEWER_DIGEST", True)
+ @patch("fitcheck.app_settings.FITCHECK_NOTIFY_PILOTS_STALE", False)
+ def test_first_access_seeds_from_legacy_app_settings(self):
+ """clean_setting reads Django settings at import time, so app_settings
+ module attributes are patched directly (not the Django setting) -
+ NotificationSettings.current() re-imports them from fitcheck.app_settings
+ on every call, so the patch takes effect."""
+ settings_obj = NotificationSettings.current()
+ self.assertFalse(settings_obj.notify_reviewers_new_submission)
+ self.assertTrue(settings_obj.reviewer_digest)
+ self.assertFalse(settings_obj.notify_pilots_stale)
+ # No legacy equivalent for this type - always defaults True.
+ self.assertTrue(settings_obj.notify_member_decision)
+
+ @patch("fitcheck.app_settings.FITCHECK_NOTIFY_REVIEWERS", False)
+ def test_second_access_does_not_reseed(self):
+ first = NotificationSettings.current()
+ first.notify_reviewers_new_submission = True
+ first.save()
+ with patch("fitcheck.app_settings.FITCHECK_NOTIFY_REVIEWERS", False):
+ second = NotificationSettings.current()
+ # The row already existed - the (patched) legacy default is ignored.
+ self.assertTrue(second.notify_reviewers_new_submission)
+
+
+class UserNotificationPreferenceTests(TestCase):
+ def test_is_muted_false_when_no_row_exists(self):
+ user = create_user("nopref")
+ self.assertFalse(UserNotificationPreference.is_muted(user))
+
+ def test_is_muted_false_for_none_or_anonymous(self):
+ self.assertFalse(UserNotificationPreference.is_muted(None))
+
+ def test_is_muted_reflects_row(self):
+ user = create_user("hasmuted")
+ UserNotificationPreference.objects.create(user=user, mute_all=True)
+ self.assertTrue(UserNotificationPreference.is_muted(user))
+ UserNotificationPreference.objects.filter(user=user).update(mute_all=False)
+ self.assertFalse(UserNotificationPreference.is_muted(user))
+
+
+class NotificationSettingsPageTests(TestCase):
+ URL = "fitcheck:notification_settings"
+
+ def test_requires_manage_policies(self):
+ user = create_user("notif_nobody")
+ self.client.force_login(user)
+ response = self.client.get(reverse(self.URL))
+ self.assertEqual(response.status_code, 302) # AA perm decorator redirects
+
+ def test_get_renders_defaults(self):
+ user = _grant(create_user("notif_admin"), "manage_policies")
+ self.client.force_login(user)
+ response = self.client.get(reverse(self.URL))
+ self.assertEqual(response.status_code, 200)
+ self.assertContains(response, "Notification Settings")
+ self.assertTrue(
+ response.context["form"].instance.notify_reviewers_new_submission
+ )
+
+ def test_post_saves_and_rereads(self):
+ user = _grant(create_user("notif_editor"), "manage_policies")
+ self.client.force_login(user)
+ response = self.client.post(
+ reverse(self.URL),
+ {
+ "notify_reviewers_new_submission": "",
+ "reviewer_digest": "on",
+ "notify_member_decision": "",
+ "notify_pilots_stale": "",
+ },
+ )
+ self.assertRedirects(response, reverse(self.URL))
+ settings_obj = NotificationSettings.current()
+ self.assertFalse(settings_obj.notify_reviewers_new_submission)
+ self.assertTrue(settings_obj.reviewer_digest)
+ self.assertFalse(settings_obj.notify_member_decision)
+ self.assertFalse(settings_obj.notify_pilots_stale)
+
+
+class ToggleMuteEndpointTests(TestCase):
+ URL = "fitcheck:toggle_notification_mute"
+
+ def test_requires_login(self):
+ response = self.client.post(reverse(self.URL), {"mute_all": "on"})
+ self.assertEqual(response.status_code, 302)
+ self.assertFalse(UserNotificationPreference.objects.exists())
+
+ def test_requires_basic_access(self):
+ # create_user() without permissions has no fitcheck perms at all.
+ user = User.objects.create_user(username="noperm", password="password")
+ self.client.force_login(user)
+ response = self.client.post(reverse(self.URL), {"mute_all": "on"})
+ self.assertEqual(response.status_code, 302)
+ self.assertFalse(UserNotificationPreference.objects.exists())
+
+ def test_post_mutes_own_row_only(self):
+ # Not following the redirect: pilot_fittings triggers an SDE
+ # auto-load check, and this test has no SDE fixture loaded.
+ user = create_user("mute_me")
+ other = create_user("mute_other")
+ self.client.force_login(user)
+ response = self.client.post(reverse(self.URL), {"mute_all": "on"})
+ self.assertRedirects(
+ response, reverse("fitcheck:pilot_fittings"), fetch_redirect_response=False
+ )
+ self.assertTrue(UserNotificationPreference.is_muted(user))
+ self.assertFalse(UserNotificationPreference.is_muted(other))
+
+ def test_unchecked_checkbox_unmutes(self):
+ user = create_user("unmute_me")
+ UserNotificationPreference.objects.create(user=user, mute_all=True)
+ self.client.force_login(user)
+ # An unchecked HTML checkbox is omitted from the POST body entirely.
+ self.client.post(reverse(self.URL), {})
+ self.assertFalse(UserNotificationPreference.is_muted(user))
+
+ def test_get_not_allowed(self):
+ user = create_user("mute_get")
+ self.client.force_login(user)
+ response = self.client.get(reverse(self.URL))
+ self.assertEqual(response.status_code, 405)
+
+
+class _NotifyDrivingBase(TestCase):
+ @classmethod
+ def setUpTestData(cls):
+ create_sde_testdata()
+ cls.doctrine = create_doctrine()
+ cls.fit = create_fit(cls.doctrine, T.HARBINGER, name="Armor Brawl")
+ add_item(cls.fit, Section.LOW, T.HEAT_SINK_II, 3)
+ cls.member = create_user("member")
+ cls.reviewer = create_user(
+ "reviewer", permissions=["basic_access", "review_submissions"]
+ )
+ cls.other_reviewer = create_user(
+ "other_reviewer", permissions=["basic_access", "review_submissions"]
+ )
+
+ def _submit(self, user=None):
+ return submit_fit(
+ user or self.member,
+ self.fit,
+ parse_eft(COMPLIANT_EFT),
+ source=FitSubmission.Source.ESI,
+ doctrine=self.doctrine,
+ )
+
+ def _set(self, **fields):
+ settings_obj = NotificationSettings.current()
+ for name, value in fields.items():
+ setattr(settings_obj, name, value)
+ settings_obj.save()
+ return settings_obj
+
+
+class ReviewerPingToggleTests(_NotifyDrivingBase):
+ def test_toggle_off_suppresses_reviewer_ping(self):
+ self._set(notify_reviewers_new_submission=False)
+ submission = self._submit()
+ Notification.objects.all().delete()
+
+ notify_reviewers_new_submission(submission.pk)
+
+ self.assertFalse(Notification.objects.filter(user=self.reviewer).exists())
+
+ def test_toggle_on_sends_reviewer_ping(self):
+ self._set(notify_reviewers_new_submission=True)
+ submission = self._submit()
+ Notification.objects.all().delete()
+
+ notify_reviewers_new_submission(submission.pk)
+
+ self.assertTrue(Notification.objects.filter(user=self.reviewer).exists())
+
+ def test_muted_reviewer_excluded_others_still_notified(self):
+ self._set(notify_reviewers_new_submission=True)
+ UserNotificationPreference.objects.create(user=self.reviewer, mute_all=True)
+ submission = self._submit()
+ Notification.objects.all().delete()
+
+ notify_reviewers_new_submission(submission.pk)
+
+ self.assertFalse(Notification.objects.filter(user=self.reviewer).exists())
+ self.assertTrue(Notification.objects.filter(user=self.other_reviewer).exists())
+
+
+class ReviewerDigestToggleTests(_NotifyDrivingBase):
+ def test_toggle_off_sends_nothing(self):
+ self._set(reviewer_digest=False)
+ self._submit()
+ Notification.objects.all().delete()
+
+ send_review_digest()
+
+ self.assertFalse(Notification.objects.filter(user=self.reviewer).exists())
+
+ def test_toggle_on_sends_summary(self):
+ self._set(reviewer_digest=True)
+ self._submit()
+ Notification.objects.all().delete()
+
+ send_review_digest()
+
+ self.assertTrue(Notification.objects.filter(user=self.reviewer).exists())
+
+ def test_muted_reviewer_excluded_from_digest(self):
+ self._set(reviewer_digest=True)
+ UserNotificationPreference.objects.create(user=self.reviewer, mute_all=True)
+ self._submit()
+ Notification.objects.all().delete()
+
+ send_review_digest()
+
+ self.assertFalse(Notification.objects.filter(user=self.reviewer).exists())
+ self.assertTrue(Notification.objects.filter(user=self.other_reviewer).exists())
+
+
+class MemberDecisionToggleTests(_NotifyDrivingBase):
+ def _decide(self, submission, approve=True):
+ submission.status = (
+ FitSubmission.Status.APPROVED if approve else FitSubmission.Status.REJECTED
+ )
+ submission.reviewed_by = self.reviewer
+ submission.reviewed_at = timezone.now()
+ submission.save()
+ return submission
+
+ def test_toggle_off_suppresses_decision_notice(self):
+ self._set(notify_member_decision=False)
+ submission = self._decide(self._submit())
+ Notification.objects.all().delete()
+
+ notify_member_decision(submission.pk)
+
+ self.assertFalse(Notification.objects.filter(user=self.member).exists())
+
+ def test_toggle_on_sends_decision_notice(self):
+ self._set(notify_member_decision=True)
+ submission = self._decide(self._submit())
+ Notification.objects.all().delete()
+
+ notify_member_decision(submission.pk)
+
+ self.assertTrue(Notification.objects.filter(user=self.member).exists())
+
+ def test_toggle_on_sends_notice_for_rule_approval(self):
+ self._set(notify_member_decision=True)
+ self.doctrine.auto_approve = Doctrine.AutoApprove.COMPLIANT
+ self.doctrine.save(update_fields=["auto_approve"])
+ submission = self._submit()
+ self.assertIsNone(submission.reviewed_by)
+ self.assertIsNotNone(submission.reviewed_at)
+ Notification.objects.all().delete()
+
+ notify_member_decision(submission.pk)
+
+ note = Notification.objects.get(user=self.member)
+ self.assertIn("by rule", note.title)
+
+ def test_muted_pilot_gets_no_decision_notice_human(self):
+ self._set(notify_member_decision=True)
+ UserNotificationPreference.objects.create(user=self.member, mute_all=True)
+ submission = self._decide(self._submit())
+ Notification.objects.all().delete()
+
+ notify_member_decision(submission.pk)
+
+ self.assertFalse(Notification.objects.filter(user=self.member).exists())
+
+ def test_muted_pilot_gets_no_decision_notice_by_rule(self):
+ self._set(notify_member_decision=True)
+ UserNotificationPreference.objects.create(user=self.member, mute_all=True)
+ self.doctrine.auto_approve = Doctrine.AutoApprove.COMPLIANT
+ self.doctrine.save(update_fields=["auto_approve"])
+ submission = self._submit()
+ Notification.objects.all().delete()
+
+ notify_member_decision(submission.pk)
+
+ self.assertFalse(Notification.objects.filter(user=self.member).exists())
+
+
+class StaleRecheckToggleTests(_NotifyDrivingBase):
+ def _archive_current_bom_and_change_it(self):
+ """Archive the fit's current BOM at its current version, then mutate
+ the live BOM and bump the version - mirrors update_fit_bom without
+ pulling in the EFT-import machinery this task doesn't depend on."""
+ from ..models import ArchivedFitVersion
+
+ old_version = self.fit.version
+ item = self.fit.items.get(module_type_id=T.HEAT_SINK_II)
+ ArchivedFitVersion.objects.create(
+ fit=self.fit,
+ version=old_version,
+ eft_source=self.fit.eft_source,
+ ship_type_id=T.HARBINGER,
+ policy_snapshot={
+ "items": [
+ {
+ "section": item.section,
+ "type_id": item.module_type_id,
+ "name": item.module_type.name,
+ "qty": item.quantity,
+ }
+ ]
+ },
+ )
+ item.quantity = 5
+ item.save(update_fields=["quantity"])
+ self.fit.bump_version()
+
+ def test_toggle_off_suppresses_all_recheck_notifications(self):
+ self._set(notify_pilots_stale=False)
+ submission = self._submit()
+ self._archive_current_bom_and_change_it()
+ Notification.objects.all().delete()
+
+ recheck_pending_submissions(self.fit.pk)
+
+ self.assertFalse(Notification.objects.filter(user=self.member).exists())
+
+ def test_toggle_on_sends_recheck_notification(self):
+ self._set(notify_pilots_stale=True)
+ submission = self._submit()
+ self._archive_current_bom_and_change_it()
+ Notification.objects.all().delete()
+
+ recheck_pending_submissions(self.fit.pk)
+
+ self.assertTrue(Notification.objects.filter(user=self.member).exists())
+
+ def test_muted_pilot_gets_no_stale_notice(self):
+ self._set(notify_pilots_stale=True)
+ UserNotificationPreference.objects.create(user=self.member, mute_all=True)
+ submission = self._submit()
+ self._archive_current_bom_and_change_it()
+ Notification.objects.all().delete()
+
+ recheck_pending_submissions(self.fit.pk)
+
+ self.assertFalse(Notification.objects.filter(user=self.member).exists())
diff --git a/fitcheck/tests/test_stale_notify.py b/fitcheck/tests/test_stale_notify.py
index fb095ea..cf001f2 100644
--- a/fitcheck/tests/test_stale_notify.py
+++ b/fitcheck/tests/test_stale_notify.py
@@ -12,7 +12,7 @@
from django.urls import reverse
from ..constants import Section
-from ..models import ArchivedFitVersion, FitSubmission, SdeType
+from ..models import ArchivedFitVersion, FitSubmission, NotificationSettings, SdeType
from ..services.check_runner import submit_fit
from ..services.eft_parser import parse_eft
from ..services.fit_diff import archive_for_version, bom_diff, diff_for_submission
@@ -252,9 +252,11 @@ def test_stale_rejected_never_notified(self, notify_mock):
notify_mock.assert_not_called()
- @patch("fitcheck.tasks.FITCHECK_NOTIFY_PILOTS_STALE", False)
@patch("fitcheck.tasks.notify")
def test_notify_disabled_suppresses_stale_notifications(self, notify_mock):
+ settings_obj = NotificationSettings.current()
+ settings_obj.notify_pilots_stale = False
+ settings_obj.save()
submission = self._submit()
self._archive_current_bom_and_change_it()
@@ -263,14 +265,11 @@ def test_notify_disabled_suppresses_stale_notifications(self, notify_mock):
# Pending re-grade still happens (fit_version catches up)...
submission.refresh_from_db()
self.assertEqual(submission.fit_version, self.fit.version)
- # ...but no stale-style notify (with its "re-checked"/diff wording) fires.
- # The BOM change here also flips the verdict, so the old verdict-changed
- # branch legitimately still notifies once - it uses different copy and
- # carries no diff appendix, so we assert on that instead of call count.
- for call in notify_mock.call_args_list:
- message = call.kwargs.get("message", "")
- self.assertNotIn("What changed:", message)
- self.assertNotIn("policy rules changed", message)
+ # ...but no notification of any kind fires - the rich stale-diff
+ # notify and the plain verdict-changed fallback are one logical event
+ # ("a fit change touched your submission"), both gated by the single
+ # notify_pilots_stale toggle.
+ notify_mock.assert_not_called()
@patch("fitcheck.tasks.notify")
def test_non_stale_pending_with_unchanged_verdict_not_notified(self, notify_mock):
diff --git a/fitcheck/urls.py b/fitcheck/urls.py
index 5adec57..a6d8307 100644
--- a/fitcheck/urls.py
+++ b/fitcheck/urls.py
@@ -61,6 +61,11 @@
# Pilot Fittings tab (own submissions + ESI ship validation)
path("pilot/", member.pilot_fittings, name="pilot_fittings"),
path("pilot/inventory/", member.ship_inventory, name="ship_inventory"),
+ path(
+ "pilot/notifications/mute/",
+ member.toggle_notification_mute,
+ name="toggle_notification_mute",
+ ),
# One SSO consent for every pilot ESI scope (assets/structures/implants/
# fittings-write), replacing the per-scope grant flows.
path("pilot/connect-esi/", member.grant_all_esi, name="grant_all_esi"),
@@ -197,6 +202,11 @@
# Settings hub (fittings import + enforcement / global settings)
path("settings/", manage.settings_home, name="settings_home"),
path("settings/parameters/", manage.scan_parameters, name="scan_parameters"),
+ path(
+ "settings/notifications/",
+ manage.notification_settings,
+ name="notification_settings",
+ ),
path("settings/diagnostics/", manage.diagnostics, name="diagnostics"),
path(
"settings/diagnostics/snapshots/run/",
diff --git a/fitcheck/views/manage.py b/fitcheck/views/manage.py
index f776cc0..a2ff6d9 100644
--- a/fitcheck/views/manage.py
+++ b/fitcheck/views/manage.py
@@ -28,6 +28,7 @@
FitImportForm,
FitItemPolicyFormSet,
FitSettingsForm,
+ NotificationSettingsForm,
OverrideAddForm,
PolicySlotRuleForm,
ScanParametersForm,
@@ -42,6 +43,7 @@
EnforcementSettings,
FitItemOverride,
FitSubmission,
+ NotificationSettings,
PolicySlotRule,
ScanParameters,
SdeType,
@@ -2097,6 +2099,29 @@ def scan_parameters(request):
)
+@login_required
+@permission_required("fitcheck.manage_policies")
+def notification_settings(request):
+ """Per-type on/off switches for the Alliance Auth notifications Fit Check
+ emits (reviewer pings/digest, pilot decision + stale notices). Replaces
+ the need to touch the legacy FITCHECK_NOTIFY_*/FITCHECK_REVIEWER_DIGEST
+ Django settings, which now only seed this row's defaults on first use."""
+ settings_obj = NotificationSettings.current()
+ if request.method == "POST":
+ form = NotificationSettingsForm(request.POST, instance=settings_obj)
+ if form.is_valid():
+ form.save()
+ messages.success(request, _("Notification settings saved."))
+ return redirect("fitcheck:notification_settings")
+ else:
+ form = NotificationSettingsForm(instance=settings_obj)
+ return render(
+ request,
+ "fitcheck/settings/notifications.html",
+ {"form": form, "page_title": _("Notification Settings")},
+ )
+
+
def _policy_section_label(section: str) -> str:
if section == Section.CARGO:
return str(_("Cargo / fuel bay"))
diff --git a/fitcheck/views/member.py b/fitcheck/views/member.py
index fd1fdde..e133d9b 100644
--- a/fitcheck/views/member.py
+++ b/fitcheck/views/member.py
@@ -17,7 +17,13 @@
from ..forms import AssignFittingForm, DoctrineCategoryForm, DoctrineForm
from ..managers import visible_categories_among, visible_categories_for
-from ..models import Doctrine, DoctrineFit, DoctrineFitItem, FitSubmission
+from ..models import (
+ Doctrine,
+ DoctrineFit,
+ DoctrineFitItem,
+ FitSubmission,
+ UserNotificationPreference,
+)
from ..services.check_runner import (
build_deficit_multibuy,
gradeable_doctrines_for,
@@ -464,6 +470,7 @@ def pilot_fittings(request):
"verdicts": FitSubmission.Verdict,
"main_character": getattr(request.user.profile, "main_character", None),
"sde_loaded": ensure_sde_loading(),
+ "notifications_muted": UserNotificationPreference.is_muted(request.user),
"page_title": _("Pilot Fittings"),
},
)
@@ -919,6 +926,26 @@ def submissions_delete_bulk(request):
return redirect("fitcheck:pilot_fittings")
+@login_required
+@permission_required("fitcheck.basic_access")
+@require_POST
+def toggle_notification_mute(request):
+ """Set the pilot's own Fit Check notification mute (own row only). Muting
+ suppresses every fitcheck notification - reviewer pings/digest, decision
+ notices, and stale notices alike - since a muted user simply never has a
+ Notification row created for them, which also silences any Discord relay
+ (e.g. aa-discordnotify) since those relay existing Notification rows."""
+ mute_all = request.POST.get("mute_all") == "on"
+ UserNotificationPreference.objects.update_or_create(
+ user=request.user, defaults={"mute_all": mute_all}
+ )
+ if mute_all:
+ messages.success(request, _("Fit Check notifications muted."))
+ else:
+ messages.success(request, _("Fit Check notifications unmuted."))
+ return redirect("fitcheck:pilot_fittings")
+
+
@login_required
@permission_required("fitcheck.basic_access")
@require_POST