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." %} -
+
+
+ {% csrf_token %} + + +
{% 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 "Notification Settings" %} + + {% translate "Back" %} + +
+
+

+ {% 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." %} +

+
+ {% csrf_token %} +
+ {{ form.notify_reviewers_new_submission }} + +
{{ form.notify_reviewers_new_submission.help_text }}
+
+
+ {{ form.reviewer_digest }} + +
{{ form.reviewer_digest.help_text }}
+
+
+ {{ form.notify_member_decision }} + +
{{ form.notify_member_decision.help_text }}
+
+
+ {{ form.notify_pilots_stale }} + +
{{ form.notify_pilots_stale.help_text }}
+
+ +
+
+
+
+
+{% 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