Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions fitcheck/app_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
55 changes: 55 additions & 0 deletions fitcheck/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
DoctrineFit,
DoctrineFitItem,
EnforcementSettings,
NotificationSettings,
ScanParameters,
SdeType,
)
Expand Down Expand Up @@ -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."
),
}
Original file line number Diff line number Diff line change
@@ -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',
},
),
]
2 changes: 2 additions & 0 deletions fitcheck/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions fitcheck/models/settings.py
Original file line number Diff line number Diff line change
@@ -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 _

Expand Down Expand Up @@ -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()
Loading