Skip to content
Open
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
55 changes: 55 additions & 0 deletions back/admin/integrations/tasks.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging

from django.contrib.auth import get_user_model
from django_q.tasks import async_task

from admin.integrations.models import Integration
from admin.integrations.sync_userinfo import SyncUsers
Expand Down Expand Up @@ -74,3 +75,57 @@ def backfill_integration_ids(integration_id):
"not_found": not_found,
"errored": errored,
}


def refresh_access_for_user_integration(integration_id, user_id):
# One `exists` lookup for a single (integration, user) pair. Enqueued
# individually so a slow or failing integration can't stall the whole
# refresh. Errors are swallowed and logged — one bad lookup must not
# prevent the other queued tasks from running.
try:
integration = Integration.objects.get(id=integration_id)
user = get_user_model().objects.get(id=user_id)
except (Integration.DoesNotExist, get_user_model().DoesNotExist):
return
try:
integration.user_exists(user, save_result=True)
except Exception as e:
logger.warning(
"Refresh error for integration %s, user %s: %s",
integration_id, user.email, e,
)


def refresh_access_report():
# Enqueue one background task per (integration, user) pair instead of
# running every lookup inline — a single big task was timing out on
# orgs with lots of staff. The worker pool processes the queue, which
# also provides natural throttling against per-service rate limits.
User = get_user_model()
integrations = (
Integration.objects.account_provision_options().filter(is_active=True)
)
user_ids = list(
User.objects.filter(is_active=True)
.exclude(email="")
.order_by("id")
.values_list("id", flat=True)
)

enqueued = 0
for integration in integrations:
# Manual-provisioning integrations don't make HTTP calls; their
# IntegrationUser rows only change via the toggle button. Skip them.
if integration.skip_user_provisioning:
continue
for user_id in user_ids:
async_task(
"admin.integrations.tasks.refresh_access_for_user_integration",
integration.id,
user_id,
task_name=f"Refresh access: {integration.name} #{user_id}",
)
enqueued += 1

logger.info("Access report refresh enqueued: %s tasks", enqueued)
return {"enqueued": enqueued}
113 changes: 113 additions & 0 deletions back/admin/integrations/templates/access_report.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
{% extends 'admin_base.html' %}
{% load i18n %}

{% block content %}
<div class="col-12">
<div class="card">
<div class="card-header">
<form method="get" class="row g-2 align-items-center w-100">
<div class="col-auto">
<select name="role" class="form-select" onchange="this.form.submit()">
<option value="all" {% if role_filter == "all" %}selected{% endif %}>{% translate "All users" %}</option>
<option value="newhire" {% if role_filter == "newhire" %}selected{% endif %}>{% translate "New hires only" %}</option>
<option value="colleague" {% if role_filter == "colleague" %}selected{% endif %}>{% translate "Colleagues only" %}</option>
</select>
</div>
<div class="col-auto">
<input type="search" name="q" value="{{ search }}" class="form-control" placeholder="{% translate 'Search by name or email...' %}">
</div>
<div class="col-auto">
<button type="submit" class="btn">{% translate "Search" %}</button>
</div>
<div class="col-auto">
<select name="per_page" class="form-select" onchange="this.form.submit()" aria-label="{% translate 'Users per page' %}">
{% for size in page_size_options %}
<option value="{{ size }}" {% if per_page == size %}selected{% endif %}>{% blocktranslate with size=size %}{{ size }} per page{% endblocktranslate %}</option>
{% endfor %}
</select>
</div>
<div class="col-auto ms-auto">
{% if request.user.is_admin %}
<button type="submit" form="access-report-refresh-form" class="btn">
{% translate "Refresh data" %}
</button>
{% endif %}
<a href="{% url 'integrations:access-report-csv' %}?{{ preserved_query }}" class="btn btn-primary">
{% translate "Download CSV" %}
</a>
</div>
</form>
{% if request.user.is_admin %}
<form id="access-report-refresh-form" method="post" action="{% url 'integrations:access-report-refresh' %}" class="d-none">
{% csrf_token %}
</form>
{% endif %}
</div>
<div class="table-responsive">
<table class="table table-vcenter table-nowrap">
<thead>
<tr>
<th style="position: sticky; left: 0; background: var(--tblr-card-bg, #fff); z-index: 2;">{% translate "User" %}</th>
{% for integration in integrations %}
<th>{{ integration.name }}</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% for row in rows %}
<tr>
<td style="position: sticky; left: 0; background: var(--tblr-card-bg, #fff);">
<a href="{% if row.user.role == 0 %}{% url 'people:new_hire_access' row.user.id %}{% else %}{% url 'people:colleague_access' row.user.id %}{% endif %}">
{{ row.user.full_name }}
</a>
<div class="text-muted small">{{ row.user.email }}</div>
</td>
{% for cell in row.cells %}
{% if cell == "active" %}
<td><span class="badge bg-green-lt">{% translate "Active" %}</span></td>
{% elif cell == "revoked" %}
<td><span class="badge bg-secondary-lt">{% translate "Not Active" %}</span></td>
{% else %}
<td><span class="text-muted">—</span></td>
{% endif %}
{% endfor %}
</tr>
{% empty %}
<tr>
<td colspan="{{ integrations|length|add:1 }}">{% translate "No users found" %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if page_obj.has_other_pages %}
<div class="card-footer d-flex align-items-center">
<p class="m-0 text-muted">
{% blocktranslate with start=page_obj.start_index end=page_obj.end_index total=paginator.count %}Showing {{ start }} to {{ end }} of {{ total }} users{% endblocktranslate %}
</p>
<ul class="pagination m-0 ms-auto">
{% if page_obj.has_previous %}
<li class="page-item">
<a class="page-link" href="?{{ preserved_query }}&page={{ page_obj.previous_page_number }}">
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><polyline points="15 6 9 12 15 18"></polyline></svg>
{% translate "prev" %}
</a>
</li>
<li class="page-item"><a class="page-link" href="?{{ preserved_query }}&page=1">1</a></li>
{% endif %}
<li class="page-item active"><a class="page-link" href="?{{ preserved_query }}&page={{ page_obj.number }}">{{ page_obj.number }}</a></li>
{% if page_obj.has_next %}
<li class="page-item"><a class="page-link" href="?{{ preserved_query }}&page={{ page_obj.next_page_number }}">{{ page_obj.next_page_number }}</a></li>
<li class="page-item">
<a class="page-link" href="?{{ preserved_query }}&page={{ paginator.num_pages }}">
{% translate "last" %}
<svg xmlns="http://www.w3.org/2000/svg" class="icon" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"><path stroke="none" d="M0 0h24v24H0z" fill="none"></path><polyline points="9 6 15 12 9 18"></polyline></svg>
</a>
</li>
{% endif %}
</ul>
</div>
{% endif %}
</div>
</div>
{% endblock %}
29 changes: 29 additions & 0 deletions back/admin/integrations/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -1079,6 +1079,35 @@ def test_get_value_from_notation():
with pytest.raises(KeyError):
get_value_from_notation("two", test_data)

# filter syntax: top-level field (existing behavior, must keep working)
test_data = {
"data": [
{"id": "A", "email": "a@x.com"},
{"id": "B", "email": "b@x.com"},
]
}
assert get_value_from_notation("data[email=b@x.com].id", test_data) == "B"

# filter syntax: nested field via dotted path (new behavior)
test_data = {
"data": [
{"id": "1", "attributes": {"email": "a@x.com"}},
{"id": "2", "attributes": {"email": "b@x.com"}},
]
}
assert (
get_value_from_notation("data[attributes.email=b@x.com].id", test_data) == "2"
)

# filter syntax: nested miss raises KeyError, same as top-level miss
with pytest.raises(KeyError):
get_value_from_notation("data[attributes.email=nope@x.com].id", test_data)

# filter syntax: dotted path that doesn't exist on items raises KeyError
test_data = {"data": [{"id": "1", "attributes": {"email": "a@x.com"}}]}
with pytest.raises(KeyError):
get_value_from_notation("data[attributes.missing=a@x.com].id", test_data)


@pytest.mark.django_db
def test_get_value_from_notation_empty_notation():
Expand Down
15 changes: 15 additions & 0 deletions back/admin/integrations/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@
views.IntegrationTrackerDetailView.as_view(),
name="tracker",
),
path(
"access-report/",
views.IntegrationAccessReportView.as_view(),
name="access-report",
),
path(
"access-report/csv/",
views.IntegrationAccessReportCSVView.as_view(),
name="access-report-csv",
),
path(
"access-report/refresh/",
views.IntegrationAccessReportRefreshView.as_view(),
name="access-report-refresh",
),
path(
"builder/",
builder_views.IntegrationBuilderCreateView.as_view(),
Expand Down
15 changes: 14 additions & 1 deletion back/admin/integrations/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,21 @@ def get_value_from_notation(notation, value):
raise KeyError

field, _, expected = filter_expr.partition("=")
# field may be a dotted path (e.g. attributes.email) for APIs
# that nest values under attributes, like HackerOne / JSON:API.
# Single-segment paths preserve the original top-level behavior.
field_path = field.split(".")
for item in value:
if isinstance(item, dict) and str(item.get(field, "")) == expected:
if not isinstance(item, dict):
continue
candidate = item
for part in field_path:
if isinstance(candidate, dict) and part in candidate:
candidate = candidate[part]
else:
candidate = None
break
if candidate is not None and str(candidate) == expected:
value = item
break
else:
Expand Down
Loading