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
2 changes: 2 additions & 0 deletions ietf/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from ietf import api
from ietf.doc import views_ballot, api as doc_api
from ietf.meeting import api as meeting_api
from ietf.meeting import views as meeting_views
from ietf.submit import views as submit_views
from ietf.utils.urls import url
Expand Down Expand Up @@ -71,6 +72,7 @@
url(r'^meeting/(?P<num>[A-Za-z0-9._+-]+)/agenda-data$', meeting_views.api_get_agenda_data),
# Meeting session materials
url(r'^meeting/session/(?P<session_id>[A-Za-z0-9._+-]+)/materials$', meeting_views.api_get_session_materials),
url(r'^meeting/registration/attended/(?P<email>[^/\x00]+)/?$', meeting_api.MeetingsAttendedByEmail.as_view(), name="ietf.api.meeting.registration.attended"),
# Let MeetEcho upload bluesheets
url(r'^notify/meeting/bluesheet/?$', meeting_views.api_upload_bluesheet),
# Let MeetEcho tell us about session attendees
Expand Down
61 changes: 61 additions & 0 deletions ietf/meeting/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Copyright The IETF Trust 2026, All Rights Reserved
from django.db.models import IntegerField
from django.db.models.functions import Cast
from rest_framework import generics

from ietf.meeting.models import Meeting
from ietf.meeting.serializers import PersonAttendedMeetingsSerializer
from ietf.person.models import Person


class MeetingsAttendedByEmail(generics.RetrieveAPIView):
"""List meetings attended by a person identified by email address
Requires API key authentication
"""

queryset = Person.objects.all()
lookup_field = "email__address"
lookup_url_kwarg = "email"
serializer_class = PersonAttendedMeetingsSerializer
api_key_endpoint = "ietf.api.meeting.registration.attended"

def get_object(self):
person = super().get_object()
assert isinstance(person, Person)
person.attended_registrations = self._attended_registrations(person)
return person

@staticmethod
def _attended_registrations(person: Person):
meetings_with_data = (
Meeting.objects.filter(type="ietf")
.annotate(number_as_int=Cast("number", output_field=IntegerField()))
.exclude(number_as_int__lt=110)
.values_list("pk")
)
has_attended_record = (
person.attended_set.filter(
session__meeting_id__in=meetings_with_data,
session__meeting__type="ietf",
)
.values_list("session__meeting__id", flat=True)
.distinct()
)
return sorted(
[
reg
for reg in (
person.registration_set.onsite_or_remote()
.with_plenary_ticket_details()
.filter(meeting_id__in=meetings_with_data)
.select_related("meeting")
)
if (
reg.attended
or reg.checkedin
or reg.meeting_id in has_attended_record
)
],
key=lambda reg: reg.meeting.date,
)
119 changes: 106 additions & 13 deletions ietf/meeting/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import datetime
import io
import os
from functools import cached_property

import pytz
import random
import re
Expand All @@ -19,7 +21,7 @@

from django.core.validators import MinValueValidator, RegexValidator
from django.db import models
from django.db.models import Max, Subquery, OuterRef, TextField, Value, Q
from django.db.models import Max, Subquery, OuterRef, TextField, Value, Q, Case, When
from django.db.models.functions import Coalesce
from django.conf import settings
from django.urls import reverse as urlreverse
Expand Down Expand Up @@ -1564,15 +1566,53 @@ def __str__(self):
return f'{self.person} at {self.session}'


class RegistrationManager(models.Manager):
class RegistrationQuerySet(models.QuerySet):
def onsite(self):
return self.get_queryset().filter(tickets__attendance_type__slug='onsite')
"""Only registrations with at least one `onsite` ticket

Includes any ticket type. In particular, may be a hackathon-only registration.
"""
return self.filter(tickets__attendance_type__slug="onsite")

def remote(self):
return self.get_queryset().filter(tickets__attendance_type__slug='remote').exclude(tickets__attendance_type__slug='onsite')
"""Only registrations with no `onsite` and at least one `remote` ticket

Includes any ticket type. In particular, may be a hackathon-only registration.
"""
return (
self.filter(tickets__attendance_type__slug="remote")
.exclude(tickets__attendance_type__slug="onsite")
)

def onsite_or_remote(self):
"""Registrations that were onsite or remote

I.e., was a registration for the plenary meeting, not e.g. hackathon-only.
"""
return self.filter(
tickets__attendance_type__slug__in=["onsite", "remote"]
).distinct()

def with_plenary_ticket_details(self):
"""Annotate with ticket details

Adds private annotations accessible via @properties
"""
most_representative = RegistrationTicket.objects.filter(
registration=OuterRef("pk")
).order_by_most_representative()
return self.annotate(
_attendance_type=Subquery(
most_representative.values("attendance_type")[:1]
),
_ticket_type=Subquery(most_representative.values("ticket_type")[:1]),
)


class Registration(models.Model):
"""Registration attendee records from the IETF registration system"""
objects = RegistrationQuerySet.as_manager() # custom manager

meeting = ForeignKey(Meeting)
first_name = models.CharField(max_length=255)
last_name = models.CharField(max_length=255)
Expand All @@ -1586,21 +1626,74 @@ class Registration(models.Model):
# checkedin indicates that the badge was picked up
checkedin = models.BooleanField(default=False)

# custom manager
objects = RegistrationManager()

def __str__(self):
return "{} {}".format(self.first_name, self.last_name)

@cached_property
def _plenary_ticket(self):
return self.tickets.order_by_most_representative().first()

@property
def attendance_type(self):
if self.tickets.filter(attendance_type__slug='onsite').exists():
return 'onsite'
elif self.tickets.filter(attendance_type__slug='remote').exists():
return 'remote'
return None
def plenary_attendance_type(self):
"""Attendance type for the plenary meeting

Attendance type for the plenary meeting. Ignores hackathon/anrw or any other
types of registration that are tracked through tickets.
"""
if hasattr(self, "_attendance_type"):
return self._attendance_type # added via with_plenary_ticket_details()
return (
self._plenary_ticket.attendance_type_id if self._plenary_ticket else None
)

@property
def plenary_ticket_type(self):
"""Ticket type for the plenary meeting

Ticket type for the plenary meeting. Ignores hackathon/anrw or any other types
of registration that are tracked through tickets.
"""
if hasattr(self, "_ticket_type"):
return self._ticket_type # added via with_plenary_ticket_details()
return (
self._plenary_ticket.ticket_type_id if self._plenary_ticket else None
)


class RegistrationTicketQuerySet(models.QuerySet):
def order_by_most_representative(self):
"""Order-by clause for representative tickets for plenary attendance

Filters out tickets not applicable to the plenary IETF meeting
"""
# ordered lists of interesting types
interesting_attendance_types = ["onsite", "remote"]
interesting_ticket_types = ["student", "week_pass", "one_day", "unknown"]
case_ranking_attendance_types = Case(
*[
When(attendance_type=att_type, then=index)
for index, att_type in enumerate(interesting_attendance_types)
]
)
case_ranking_ticket_types = Case(
*[
When(ticket_type=tkt_type, then=index)
for index, tkt_type in enumerate(interesting_ticket_types)
]
)
return self.filter(
attendance_type__in=interesting_attendance_types,
ticket_type__in=interesting_ticket_types,
).order_by(
case_ranking_attendance_types,
case_ranking_ticket_types,
"pk", # deterministic tie-break
)


class RegistrationTicket(models.Model):
objects = RegistrationTicketQuerySet.as_manager() # custom manager

registration = ForeignKey(Registration, related_name='tickets')
attendance_type = ForeignKey(AttendanceTypeName, on_delete=models.PROTECT)
ticket_type = ForeignKey(RegistrationTicketTypeName, on_delete=models.PROTECT)
Expand Down
28 changes: 28 additions & 0 deletions ietf/meeting/serializers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Copyright The IETF Trust 2026, All Rights Reserved

from rest_framework import serializers

from ietf.meeting.models import Registration


class AttendedMeetingSerializer(serializers.ModelSerializer):
"""Serialize a plenary meeting attendance record"""
meeting = serializers.SlugRelatedField(slug_field="number", read_only=True)
attendance_type = serializers.CharField(
source="plenary_attendance_type", read_only=True
)
ticket_type = serializers.CharField(
source="plenary_ticket_type", read_only=True
)

class Meta:
model = Registration
fields = [
"meeting",
"attendance_type",
"ticket_type",
]


class PersonAttendedMeetingsSerializer(serializers.Serializer):
attended = AttendedMeetingSerializer(source="attended_registrations", many=True)
Loading
Loading