Eligibility Pipeline¶
The eligibility pipeline is the most critical flow in Revel. It determines whether a user can participate in an event by running a sequence of checks called gates. Each gate evaluates a specific condition and can either pass (return None), or fail by returning an EventUserEligibility result with allowed=False and an actionable next_step. Some gates can be bypassed by invitation waivers.
This is core business logic
Changes to the eligibility pipeline affect every user's ability to join events. Any modifications must be thoroughly tested and reviewed. The EligibilityService is the single source of truth for event access decisions.
Pipeline Overview¶
The pipeline consists of 11 gates executed in strict order. The first gate to return a result short-circuits the pipeline; no subsequent gates are evaluated.
flowchart TD
Start([User requests to join event]) --> G1
G1{"1. PrivilegedAccessGate<br/>Owner or Staff?"}
G1 -->|"Yes"| Eligible([Eligible])
G1 -->|"No"| G2
G2{"2. BlacklistGate<br/>Blacklisted or fuzzy match?"}
G2 -->|"Clear"| G3
G2 -->|"Hard blacklisted"| Rejected1([Rejected: blacklisted])
G2 -->|"Fuzzy match"| Rejected1b([Rejected: verification_required])
G3{"3. EventStatusGate<br/>Event open & not ended?"}
G3 -->|"Yes"| G4
G3 -->|"No"| Rejected2([Rejected: event_not_open / finished])
G4{"4. RSVPDeadlineGate<br/>RSVP deadline not passed?<br/>(non-ticket events only)"}
G4 -->|"Yes / Waived / N/A"| G5
G4 -->|"No"| Rejected3([Rejected: rsvp_deadline_passed])
G5{"5. ApplyDeadlineGate<br/>Application deadline not passed?"}
G5 -->|"Yes / Waived / Already applied"| G6
G5 -->|"No & needs to apply"| Rejected4([Rejected: application_deadline_passed])
G6{"6. InvitationGate<br/>Private event?"}
G6 -->|"Public / Has invitation"| G7
G6 -->|"Private & no invitation"| Rejected5([Rejected: requires_invitation])
G7{"7. MembershipGate<br/>Members-only event?"}
G7 -->|"Not members-only / Is member / Waived"| G8
G7 -->|"Not a member"| Rejected6([Rejected: members_only])
G8{"8. FullProfileGate<br/>Profile complete?"}
G8 -->|"Yes / Not required"| G9
G8 -->|"Incomplete"| Rejected7([Rejected: requires_full_profile])
G9{"9. QuestionnaireGate<br/>Questionnaires passed?"}
G9 -->|"All passed / Waived / None required"| G10
G9 -->|"Incomplete"| Rejected8([Rejected: questionnaire status])
G10{"10. AvailabilityGate<br/>Capacity available?"}
G10 -->|"Yes / Overridden"| G11
G10 -->|"No"| Rejected9([Rejected: event_is_full])
G11{"11. TicketSalesGate<br/>Active sales window?"}
G11 -->|"Yes / Non-ticketed"| Eligible
G11 -->|"No"| Rejected10([Rejected: no_tickets_on_sale])
style Eligible fill:#2e7d32,color:#fff
style Rejected1 fill:#c62828,color:#fff
style Rejected1b fill:#c62828,color:#fff
style Rejected2 fill:#c62828,color:#fff
style Rejected3 fill:#c62828,color:#fff
style Rejected4 fill:#c62828,color:#fff
style Rejected5 fill:#c62828,color:#fff
style Rejected6 fill:#c62828,color:#fff
style Rejected7 fill:#c62828,color:#fff
style Rejected8 fill:#c62828,color:#fff
style Rejected9 fill:#c62828,color:#fff
style Rejected10 fill:#c62828,color:#fff Gate Details¶
1. PrivilegedAccessGate¶
The first gate provides a fast path for organization owners and staff. If the requesting user is the organization owner or a staff member, they are immediately granted access with allowed=True; no further gates are checked.
Why this is first
Owners and staff always need access to their own events for management purposes (check-in, monitoring, testing). Placing this gate first avoids unnecessary computation.
Source: events/service/event_manager/gates.py: PrivilegedAccessGate
2. BlacklistGate¶
Checks whether the user is blocked from participating in the organization's events. This gate has two levels of blocking:
- Hard block: The user is definitively blacklisted (FK match or hard identifier match). Returns
allowed=Falsewith nonext_stepand no recourse. - Soft block (fuzzy match): The user's name fuzzy-matches a blacklist entry. This requires verification through a whitelist request flow:
- Active members bypass fuzzy matching entirely (trusted users).
- If fuzzy-matched but already whitelisted, the gate passes.
- If a whitelist request is pending, returns
next_step=WAIT_FOR_WHITELIST_APPROVAL. - If a whitelist request was rejected, returns no
next_step(no recourse). - If no request exists yet, returns
next_step=REQUEST_WHITELIST.
Note
This gate cannot be waived by invitations. Blacklist checks are absolute.
Source: events/service/event_manager/gates.py: BlacklistGate
3. EventStatusGate¶
Checks the fundamental state of the event:
- Has the event's end date passed? If so, returns reason
EVENT_HAS_FINISHEDwith nonext_step. - Is the event status set to
OPEN? If not, returns reasonEVENT_IS_NOT_OPENwithnext_step=WAIT_FOR_EVENT_TO_OPEN.
Note
This gate cannot be waived. A closed or ended event is closed for everyone.
Source: events/service/event_manager/gates.py: EventStatusGate
4. RSVPDeadlineGate¶
For non-ticketed events, checks whether the RSVP deadline (event.rsvp_before) has passed.
- Ticketed events: gate is skipped entirely (returns
None). - No deadline set: gate passes.
- Deadline passed: returns
allowed=Falsewith reasonRSVP_DEADLINE_PASSED.
Invitation waiver: waives_rsvp_deadline
This gate can be waived by an EventInvitation with waives_rsvp_deadline=True. Invited users can RSVP even after the deadline has passed.
Source: events/service/event_manager/gates.py: RSVPDeadlineGate
5. ApplyDeadlineGate¶
Checks whether the application deadline has passed for users who still need to apply. The effective deadline is event.apply_before if set, otherwise falls back to the event start time (event.effective_apply_deadline).
This gate only blocks users who have not yet completed their required application steps:
- Needs to submit an invitation request (private event that accepts requests, user has no invitation and no existing request).
- Needs to complete a required questionnaire (admission questionnaire not yet submitted or previously rejected).
Users who have already applied (submitted a request or completed questionnaires) pass through even after the deadline.
Invitation waiver: waives_apply_deadline
This gate can be waived by an EventInvitation with waives_apply_deadline=True.
Source: events/service/event_manager/gates.py: ApplyDeadlineGate
6. InvitationGate¶
For private events, ensures the user has a valid EventInvitation.
- Public / members-only events: gate is skipped entirely.
- Has invitation: gate passes.
- Pending invitation request: returns
next_step=WAIT_FOR_INVITATION_APPROVAL. - Rejected invitation request: returns no
next_step(no recourse). - No invitation and no request: returns
next_step=REQUEST_INVITATIONif the event accepts invitation requests, otherwise nonext_step.
Source: events/service/event_manager/gates.py: InvitationGate
7. MembershipGate¶
For members-only events, ensures the user is an active member of the organization.
- Not a members-only event: gate is skipped.
- User is an active member: gate passes.
- User has inactive membership: returns reason
MEMBERSHIP_INACTIVEwith nonext_step. - User has no membership: returns reason
MEMBERS_ONLYwithnext_step=BECOME_MEMBERif the organization accepts membership requests.
Invitation waiver: waives_membership_required
This gate can be waived by an EventInvitation with waives_membership_required=True. Non-members can participate in members-only events if they were explicitly invited with this waiver.
Source: events/service/event_manager/gates.py: MembershipGate
8. FullProfileGate¶
When an event has requires_full_profile=True, ensures the user has a complete profile:
- Profile picture present
- Pronouns set
- Name provided (at least one of
first_name,last_name, orpreferred_name)
If any fields are missing, returns allowed=False with next_step=COMPLETE_PROFILE and a missing_profile_fields list indicating which fields need to be filled in (e.g., ["profile_picture", "pronouns", "name"]).
Note
This gate cannot be waived by invitations.
Source: events/service/event_manager/gates.py: FullProfileGate
9. QuestionnaireGate¶
Checks whether the user has submitted and passed all required admission questionnaires linked to the event's organization. This gate performs three sub-checks in order:
- Missing questionnaires: User has not submitted a required questionnaire, or their approved submission has expired (
max_submission_age). Returnsnext_step=COMPLETE_QUESTIONNAIREwithquestionnaires_missinglist. - Pending review: Submission exists but evaluation is
PENDING_REVIEWor not yet created. Returnsnext_step=WAIT_FOR_QUESTIONNAIRE_EVALUATIONwithquestionnaires_pending_reviewlist. Skipped for questionnaires withrequires_evaluation=False. - Failed questionnaires: Evaluation was
REJECTED. Skipped for questionnaires withrequires_evaluation=False.- If max attempts exceeded, returns
questionnaires_failedlist with nonext_step. - If retake is allowed and cooldown has passed (or
can_retake_afterisnull/zero for immediate retake), adds toquestionnaires_missingfor resubmission. - If retake is allowed but cooldown has not passed, returns
next_step=WAIT_TO_RETAKE_QUESTIONNAIREwithretry_ondatetime.
- If max attempts exceeded, returns
Questionnaire scoping:
- Global questionnaires: Submission is shared across all events.
- Per-event questionnaires (
per_event=True): Submission is scoped to the specific event. - Member exemptions (
members_exempt=True): Active members skip the questionnaire. - No evaluation required (
requires_evaluation=False): Only submission existence is checked — pending review and failed checks are skipped entirely. See Questionnaires: Information-Gathering Mode.
Invitation waiver: waives_questionnaire
This gate can be waived by an EventInvitation with waives_questionnaire=True. All questionnaire requirements are bypassed.
Questionnaires can use AI evaluation
Some questionnaires use LLM-powered evaluation. See Questionnaires for details on evaluation modes and scoring.
Source: events/service/event_manager/gates.py: QuestionnaireGate
10. AvailabilityGate¶
Checks whether the event has reached its capacity limit. Uses event.effective_capacity, which is the minimum of max_attendees and venue.capacity (or whichever is set; 0 means unlimited).
- Unlimited capacity (
effective_capacity == 0): gate passes. - Counting logic:
- Ticketed events: counts non-cancelled tickets.
- RSVP events: counts
YESRSVPs. - Pending unexpired non-cutoff waitlist offers also count toward capacity (minus the current user's own offer, if any) — see Advanced Waitlist.
- Event full: returns
next_step=JOIN_WAITLISTif waitlist is open and user is not already waitlisted,next_step=WAIT_FOR_OPEN_SPOTif already on the waitlist, or nonext_stepif no waitlist. - Reservation-driven fullness: when seats exist on paper but are held by waitlist offers, the gate returns
SPOTS_RESERVED_FOR_WAITLIST(orON_WAITLIST_WAITING_FOR_BATCHfor users already on the queue) instead of plainEVENT_IS_FULL.
Invitation waiver: overrides_max_attendees
This gate can be bypassed by an EventInvitation with overrides_max_attendees=True. Invited users can join even when the event is technically full.
Two-phase capacity check
This gate performs a preliminary check using prefetched in-memory data (zero DB queries). The final authoritative capacity check happens in EventManager._assert_capacity() within a database transaction with row-level locking (select_for_update) to prevent race conditions. Both layers apply the same offer-aware arithmetic; see Advanced Waitlist → Capacity & reservation semantics.
Source: events/service/event_manager/gates.py: AvailabilityGate
11. TicketSalesGate¶
For ticketed events, checks whether there is at least one ticket tier with an active sales window.
- Non-ticketed events: gate is skipped.
- For each tier, checks if the current time falls within the sales window:
sales_start_attosales_end_at(falls back toevent.startifsales_end_atis not set).- Tiers with no sales window configured are assumed to always be on sale.
- If no tier has active sales, returns reason
NO_TICKETS_ON_SALEwith nonext_step.
Note
This gate cannot be waived by invitations. Ticket sales windows are strict.
Source: events/service/event_manager/gates.py: TicketSalesGate
Response Structure¶
When a user fails a gate, the EligibilityService returns an EventUserEligibility object (a Pydantic BaseModel):
class EventUserEligibility(BaseModel):
allowed: bool
event_id: uuid.UUID
reason: str | None = None
reason_code: ReasonCode | None = None
next_step: NextStep | None = None
questionnaires_missing: list[uuid.UUID] | None = None
questionnaires_pending_review: list[uuid.UUID] | None = None
questionnaires_failed: list[uuid.UUID] | None = None
retry_on: datetime.datetime | None = None
missing_profile_fields: list[str] | None = None
| Field | Type | Description |
|---|---|---|
allowed | bool | Whether the user can participate |
event_id | UUID | The event being checked |
reason | str \| None | Translated human-readable reason (from Reasons enum) — for display only |
reason_code | ReasonCode \| None | Stable machine-readable identifier mirroring reason (snake_case, never translated) — switch on this in clients |
next_step | NextStep \| None | Suggested action the user can take to become eligible |
questionnaires_missing | list[UUID] \| None | IDs of questionnaires the user needs to complete |
questionnaires_pending_review | list[UUID] \| None | IDs of questionnaires awaiting evaluation |
questionnaires_failed | list[UUID] \| None | IDs of questionnaires the user has permanently failed |
retry_on | datetime \| None | When the user can retake a failed questionnaire |
missing_profile_fields | list[str] \| None | Profile fields that need to be completed (e.g., profile_picture, pronouns, name) |
Note
On success (allowed=True), all optional fields default to None.
NextStep Enum Values¶
All possible NextStep values and when they are returned:
| NextStep | Gate | When Suggested |
|---|---|---|
REQUEST_WHITELIST | BlacklistGate | User fuzzy-matches a blacklist entry and needs to request verification |
WAIT_FOR_WHITELIST_APPROVAL | BlacklistGate | Whitelist request is pending |
WAIT_FOR_EVENT_TO_OPEN | EventStatusGate | Event exists but is not yet open |
WAIT_FOR_INVITATION_APPROVAL | InvitationGate | Invitation request has been submitted and is pending |
REQUEST_INVITATION | InvitationGate | Private event, user can request an invitation |
BECOME_MEMBER | MembershipGate | Members-only event, organization accepts membership requests |
UPGRADE_MEMBERSHIP | MembershipGate | Reserved for membership tier requirements |
COMPLETE_PROFILE | FullProfileGate | User profile is missing required fields |
COMPLETE_QUESTIONNAIRE | QuestionnaireGate | User has not completed required questionnaires |
WAIT_FOR_QUESTIONNAIRE_EVALUATION | QuestionnaireGate | Questionnaire submitted, awaiting evaluation |
WAIT_TO_RETAKE_QUESTIONNAIRE | QuestionnaireGate | Failed questionnaire, retake cooldown has not elapsed |
JOIN_WAITLIST | AvailabilityGate | Event is full, waitlist is open, user is not on it |
WAIT_FOR_OPEN_SPOT | AvailabilityGate | Event is full, user is already on the waitlist |
PURCHASE_TICKET | EventManager | Returned by EventManager.rsvp() when event requires a ticket |
RSVP | EventManager | Returned when event requires RSVP |
Invitation Waivers Summary¶
EventInvitation objects carry boolean flags that can bypass specific gates. These are set per-invitation when an organizer creates the invitation.
Which gates can invitations bypass?
| Gate | Waiver Field | Effect |
|---|---|---|
| PrivilegedAccessGate | N/A | Already a fast path for owners/staff |
| BlacklistGate | None | Cannot be waived |
| EventStatusGate | None | Cannot be waived |
| RSVPDeadlineGate | waives_rsvp_deadline | Invited user can RSVP after the deadline |
| ApplyDeadlineGate | waives_apply_deadline | Invited user can apply after the deadline |
| InvitationGate | N/A | This is the invitation check itself |
| MembershipGate | waives_membership_required | Non-member can join members-only event |
| FullProfileGate | None | Cannot be waived |
| QuestionnaireGate | waives_questionnaire | All questionnaire requirements bypassed |
| AvailabilityGate | overrides_max_attendees | Invited user can join a full event |
| TicketSalesGate | None | Cannot be waived |
Additionally, waives_purchase is available on invitations and checked by EligibilityService.waives_purchase(). It grants complimentary access and is used downstream in the ticket checkout flow, not within the eligibility gates themselves.
Architecture¶
EligibilityService¶
The EligibilityService class (events/service/event_manager/service.py) is the orchestrator. It:
- Pre-fetches all data in
__init__using optimized queries withselect_related,prefetch_related, and annotations, ensuring subsequent gate checks require zero additional database queries. - Instantiates all 11 gates in order, passing itself as a handler so gates can access shared prefetched data.
- Runs gates sequentially in
check_eligibility(). The first gate to return anEventUserEligibilityshort-circuits the pipeline. - If all gates pass, returns
EventUserEligibility(allowed=True, event_id=...).
def check_eligibility(self, bypass: bool = False) -> EventUserEligibility:
if bypass:
return EventUserEligibility(allowed=True, event_id=self.event.pk)
for gate in self._gates:
if result := gate.check():
return result
return EventUserEligibility(allowed=True, event_id=self.event.id)
EventManager¶
The EventManager class (events/service/event_manager/manager.py) wraps EligibilityService and adds transactional operations:
rsvp(): Checks eligibility, then asserts capacity with row-level locking, then creates/updates the RSVP. Users who already have aYESRSVP bypass eligibility checks when changing status (to prevent them from being "trapped" if requirements change).check_eligibility(): Delegates toEligibilityService.check_eligibility()with an optionalraise_on_falseparameter that raisesUserIsIneligibleError.
Sequence Diagram¶
sequenceDiagram
actor User
participant API as Controller
participant EM as EventManager
participant ES as EligibilityService
participant Gates as Gates (1-11)
participant DB as Database
User->>API: POST /events/{id}/rsvp
API->>EM: rsvp(user, event)
EM->>ES: __init__(user, event)
ES->>DB: Prefetch all data (optimized queries)
ES-->>EM: Ready
EM->>ES: check_eligibility()
loop For each gate (1-11)
ES->>Gates: gate.check()
alt Gate blocks
Gates-->>ES: EventUserEligibility(allowed=False)
ES-->>EM: Return result (short-circuit)
else Gate passes
Gates-->>ES: None (continue)
end
end
ES-->>EM: EventUserEligibility(allowed=True)
alt Eligible
EM->>DB: _assert_capacity() with SELECT FOR UPDATE
EM->>DB: Create/update RSVP
EM-->>API: EventRSVP
API-->>User: 200 OK
else Not eligible
EM-->>API: UserIsIneligibleError
API-->>User: 403 with reason & next_step
end Reason Codes¶
Reasons are emitted on the wire in two forms:
reason— the English message translated at runtime viagettext. Display only — locale and prose can change.reason_code— a stable snake_case identifier from theReasonCodeenum. Switch on this in clients; it never changes when prose or translation does.
ReasonCode value | English message |
|---|---|
blacklisted | "You are not allowed to participate in this organization's events." |
verification_required | "Additional verification required." |
whitelist_pending | "Your verification request is pending approval." |
whitelist_rejected | "Your verification request was rejected." |
event_has_finished | "Event has finished." |
event_is_not_open | "Event is not open." |
rsvp_deadline_passed | "The RSVP deadline has passed." |
application_deadline_passed | "The application deadline has passed." |
requires_invitation | "Requires invitation." |
invitation_request_pending | "Your invitation request is pending approval." |
invitation_request_rejected | "Your invitation request was rejected." |
members_only | "Only members are allowed." |
membership_inactive | "Your membership is not active." |
membership_tier_required | "This ticket tier requires a specific membership tier." |
requires_full_profile | "Requires full profile." |
questionnaire_missing | "Questionnaire has not been filled." |
questionnaire_pending_review | "Waiting for questionnaire evaluation." |
questionnaire_failed | "Questionnaire evaluation was insufficient." |
questionnaire_retake_cooldown | "Questionnaire evaluation was insufficient. You can try again in {retry_on}." |
event_is_full | "Event is full." |
spots_reserved_for_waitlist | "Spots are currently reserved for waitlist members." |
on_waitlist_waiting_for_batch | "You are on the waitlist. Waiting for your turn." |
sold_out | "Sold out" |
no_tickets_on_sale | "Tickets are not currently on sale." |
requires_ticket | "Requires a ticket." |
MUST_RSVP | "Must RSVP" |
REQUIRES_PURCHASE | "Requires purchase." |
NOTHING_TO_PURCHASE | "Nothing to purchase." |