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
8 changes: 4 additions & 4 deletions moderator/moderate/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,15 @@ class UserAdmin(UserAdmin):
"email",
"first_name",
"last_name",
"is_nda_member",
"is_employee",
"is_staff",
)
search_fields = ["email", "first_name", "last_name"]

def is_nda_member(self, obj):
return obj.userprofile.is_nda_member
def is_employee(self, obj):
return obj.userprofile.is_employee

is_nda_member.boolean = True
is_employee.boolean = True


class QuestionInline(admin.StackedInline):
Expand Down
47 changes: 36 additions & 11 deletions moderator/moderate/auth.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,40 @@
from django.conf import settings
from mozilla_django_oidc.auth import OIDCAuthenticationBackend

from moderator.moderate.utils import is_legacy_username, suggest_username
from moderator.moderate.utils import (
is_employee_groups,
is_legacy_username,
suggest_username,
)

GROUPS_CLAIM = "https://sso.mozilla.com/claim/groups"


class ModeratorAuthBackend(OIDCAuthenticationBackend):
"""Override base authentication class."""

_userinfo = None
_userinfo_access_token = None

def get_userinfo(self, access_token, id_token, payload):
"""Fetch the claims once per login.

`get_or_create_user` needs them to gate the login and the base
implementation asks the provider for them again right afterwards.
"""
if self._userinfo is None or access_token != self._userinfo_access_token:
self._userinfo = super(ModeratorAuthBackend, self).get_userinfo(
access_token, id_token, payload
)
self._userinfo_access_token = access_token
return self._userinfo

def get_or_create_user(self, access_token, id_token, payload):
"""Get or create a new user only if they have one of the groups
mentioned in the ALLOWED_LOGIN_GROUPS in the claims.
"""
user_info = self.get_userinfo(access_token, id_token, payload)
groups = user_info.get("https://sso.mozilla.com/claim/groups", [])
groups = user_info.get(GROUPS_CLAIM, [])

# The user is not staff or NDA member. Return None
if not any(x in groups for x in settings.ALLOWED_LOGIN_GROUPS):
Expand All @@ -21,21 +43,24 @@ def get_or_create_user(self, access_token, id_token, payload):
access_token, id_token, payload
)

def create_user(self, claims):
user = super(ModeratorAuthBackend, self).create_user(claims)
self.update_profile(user, claims)
return user

def update_user(self, user, claims):
# Update user status (nda, staff based on assertions)
profile = user.userprofile
email = claims.get("email")
if email and user.email != email:
user.email = email
if is_legacy_username(user.username):
user.username = suggest_username(user.email)
profile.avatar_url = claims.get("avatar", "")
user.save()
self.update_profile(user, claims)
return user

# Only staff members and members of the NDA group are allowed to login.
# Because of this everyone will get the is_nda_member set to True.
# If in the future more people are allowed to login this needs to be
# available to only members of the ALLOWED_LOGIN_GROUPS
profile.is_nda_member = True
def update_profile(self, user, claims):
"""Refresh the profile fields derived from the OIDC claims."""
profile = user.userprofile
profile.avatar_url = claims.get("avatar", "")
profile.is_employee = is_employee_groups(claims.get(GROUPS_CLAIM, []))
profile.save()
return user
21 changes: 12 additions & 9 deletions moderator/moderate/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ class TomSelectMultiple(forms.SelectMultiple):
def __init__(self, autocomplete_url, attrs=None):
merged = {
"data-autocomplete-url": autocomplete_url,
"class": ((attrs or {}).get("class", "") + " tom-select form-control").strip(),
"class": (
(attrs or {}).get("class", "") + " tom-select form-control"
).strip(),
}
merged.update({k: v for k, v in (attrs or {}).items() if k not in merged})
super().__init__(attrs=merged)
Expand Down Expand Up @@ -134,17 +136,18 @@ def __init__(self, *args, **kwargs):
else:
self.fields["moderators"].initial = User.objects.filter(id=self.user.pk)
del self.fields["archived"]
if not self.user.userprofile.is_employee:
# An NDA community member would lose sight of their own event if it
# were not opted in to the NDA community.
self.fields["is_nda"].disabled = True
self.fields["is_nda"].initial = True
self.fields["is_nda"].help_text = (
"Only staff can change who an event is open to."
)

def clean(self):
"""
Clean method to check post data for nda events,
and moderated events with no moderators.
"""
"""Clean method to check for moderated events with no moderators."""
cdata = super(EventForm, self).clean()
# Do not allow non-nda members to submit NDA events.
if not self.user.userprofile.is_nda_member and cdata["is_nda"]:
msg = "Only members of the NDA group can create NDA events."
raise forms.ValidationError(msg)
# Don't allow non-superusers to modify moderation status or moderators
if not cdata["moderators"]:
msg = "An event should have at least one moderator."
Expand Down
18 changes: 18 additions & 0 deletions moderator/moderate/migrations/0023_mozillianprofile_is_employee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.2.13 on 2026-08-04 09:19

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
("moderate", "0022_alter_event_options"),
]

operations = [
migrations.AddField(
model_name="mozillianprofile",
name="is_employee",
field=models.BooleanField(default=False),
),
]
47 changes: 47 additions & 0 deletions moderator/moderate/migrations/0024_backfill_is_employee.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from django.db import migrations

# Kept separate from the schema migration: MySQL cannot roll DDL back, so a
# failure here would otherwise leave the column added and the migration
# unrecorded.

# Staff domains that do not carry the "mozilla" substring.
EMPLOYEE_EMAIL_DOMAINS = frozenset({"thunderbird.net", "getpocket.com"})


def is_employee_email(email):
"""True if `email` looks like a staff address.

Deliberately duplicated here instead of imported from the app: migrations
have to keep working when application code moves on.
"""
if not email:
return False
domain = email.rsplit("@", 1)[-1].lower()
return "mozilla" in domain or domain in EMPLOYEE_EMAIL_DOMAINS


def backfill_is_employee(apps, schema_editor):
"""Classify existing profiles by email domain.

Nothing recorded whether a profile belonged to staff before this migration,
so seed the flag from the address and let the OIDC claim correct it on each
user's next login.
"""
MozillianProfile = apps.get_model("moderate", "MozillianProfile")
employees = []
for profile in MozillianProfile.objects.select_related("user").iterator():
if is_employee_email(profile.user.email):
profile.is_employee = True
employees.append(profile)
MozillianProfile.objects.bulk_update(employees, ["is_employee"], batch_size=500)


class Migration(migrations.Migration):

dependencies = [
("moderate", "0023_mozillianprofile_is_employee"),
]

operations = [
migrations.RunPython(backfill_is_employee, migrations.RunPython.noop),
]
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from django.db import migrations

# Dropping the old column is split out so it can be applied on a later deploy,
# once no instance is running code that still reads is_nda_member.


class Migration(migrations.Migration):

dependencies = [
("moderate", "0024_backfill_is_employee"),
]

operations = [
migrations.RemoveField(
model_name="mozillianprofile",
name="is_nda_member",
),
]
21 changes: 20 additions & 1 deletion moderator/moderate/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ class MozillianProfile(models.Model):
slug = models.SlugField(blank=True, max_length=100)
username = models.CharField(max_length=40)
avatar_url = models.URLField(max_length=400, default="", blank=True)
is_nda_member = models.BooleanField(default=False)
is_employee = models.BooleanField(default=False)

def __str__(self):
return self.username
Expand Down Expand Up @@ -60,6 +60,23 @@ def create_user_profile(sender, instance, created, raw, **kwargs):
)


class EventQuerySet(models.QuerySet):
def visible_to(self, user):
"""Restrict to the events `user` is allowed to see.

Employees and superusers see everything. NDA community members see the
events opted in to the NDA community plus the ones they moderate, so a
community moderator can still find their own event.
"""
if user.is_superuser or user.userprofile.is_employee:
return self
# A subquery rather than a join on moderators: joining would duplicate
# rows and inflate the Count() annotations the listing views add.
return self.filter(
models.Q(is_nda=True) | models.Q(pk__in=user.events_moderated.values("pk"))
)


class Event(models.Model):
"""Event model."""

Expand All @@ -82,6 +99,8 @@ class Event(models.Model):
is_moderated = models.BooleanField(default=False)
users_can_vote = models.BooleanField(default=True)

objects = EventQuerySet.as_manager()

class Meta:
ordering = ["-event_date"]

Expand Down
2 changes: 1 addition & 1 deletion moderator/moderate/moderate_urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
]

question_urls = [
path("<str:q_id>/upvote", moderate_views.upvote, name="upvote"),
path("<int:q_id>/upvote", moderate_views.upvote, name="upvote"),
]

urlpatterns = [
Expand Down
3 changes: 2 additions & 1 deletion moderator/moderate/templates/create_event.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@
{% if user_can_edit %}
{{ check(event_form.users_can_vote, "Allow users to vote on questions") }}
{{ check(event_form.is_nda,
'Restrict to <a target="_blank" href="https://people.mozilla.org/a/nda">NDA Community members</a>') }}
'Allow <a target="_blank" href="https://people.mozilla.org/a/nda">NDA Community members</a>',
help=event_form.is_nda.help_text) }}
{{ check(event_form.is_moderated, "Moderated event (questions need approval)") }}
{% if event %}
{{ check(event_form.archived, "Archive this event") }}
Expand Down
2 changes: 1 addition & 1 deletion moderator/moderate/templates/questions.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
{{ event.event_date|date('F j, Y') }}
</time>
{% endif %}
{% if event.is_nda %}<span class="badge bg-warning-subtle text-warning-emphasis">NDA</span>{% endif %}
{% if event.is_nda %}<span class="badge bg-success-subtle text-success-emphasis">NDA Community</span>{% endif %}
{% if event.is_moderated %}<span class="badge bg-info-subtle text-info-emphasis">Moderated</span>{% endif %}
{% if event.archived %}<span class="badge bg-secondary-subtle text-secondary-emphasis">Archived</span>{% endif %}
</div>
Expand Down
12 changes: 12 additions & 0 deletions moderator/moderate/templatetags/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
from django.utils.safestring import mark_safe
from django_jinja import library

from moderator.moderate.models import Event


@library.global_function
def user_voted(question, user):
Expand All @@ -24,6 +26,16 @@ def can_moderate_event(event, user):
return user.is_superuser or event.moderators.filter(id=user.id).exists()


@library.global_function
def can_access_event(event, user):
"""Check if a user can see an event.

Delegates to the queryset so the object level and the list level rules
cannot drift apart.
"""
return Event.objects.filter(pk=event.pk).visible_to(user).exists()


@library.global_function
def can_answer_question(question, user):
"""Check if a user can answer a question."""
Expand Down
22 changes: 22 additions & 0 deletions moderator/moderate/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,28 @@
import pytest


def pytest_configure(config):
from django.conf import settings

settings.SESSION_COOKIE_SECURE = False
settings.CSRF_COOKIE_SECURE = False
settings.SECURE_HSTS_SECONDS = 0


@pytest.fixture
def make_user(db):
"""Build a user whose profile is flagged as staff or NDA community."""

def _make_user(username, is_employee=False, superuser=False):
from django.contrib.auth.models import User

create = (
User.objects.create_superuser if superuser else User.objects.create_user
)
user = create(username=username, email=f"{username}@example.com", password="x")
profile = user.userprofile
profile.is_employee = is_employee
profile.save()
return user

return _make_user
Loading
Loading