diff --git a/.env.template b/.env.template index 71fae4c5..20fa8bff 100644 --- a/.env.template +++ b/.env.template @@ -8,3 +8,7 @@ RECAPTCHA_PRIVATE_KEY='dummy_value' # The toolbar should not be used in a remote environment ENABLE_TOOLBAR="" DJANGO_RUNSERVER_HIDE_WARNING="True" +# GitHub API Configuration (for Djangonaut stats - Issue #615) +# Get token from: https://github.com/settings/tokens +# Required scopes: public_repo +GITHUB_TOKEN= diff --git a/.env.template.local b/.env.template.local index 6edc11d3..f58894d4 100644 --- a/.env.template.local +++ b/.env.template.local @@ -8,3 +8,7 @@ ENABLE_TOOLBAR="" PLAYWRIGHT_TEST_USERNAME="playwright_test" PLAYWRIGHT_TEST_PASSWORD="hunter2" DJANGO_RUNSERVER_HIDE_WARNING="True" +# GitHub API Configuration (for Djangonaut stats - Issue #615) +# Get token from: https://github.com/settings/tokens +# Required scopes: public_repo +GITHUB_TOKEN= diff --git a/home/admin.py b/home/admin.py index d23a7c29..c7f99a76 100644 --- a/home/admin.py +++ b/home/admin.py @@ -38,6 +38,7 @@ send_session_results_view, send_team_welcome_emails_view, ) +from .views.sessions import collect_stats_view User = get_user_model() @@ -50,8 +51,9 @@ class EventAdmin(DescriptiveSearchMixin, admin.ModelAdmin): @admin.register(Project) class ProjectAdmin(DescriptiveSearchMixin, admin.ModelAdmin): - list_display = ("name", "url") - search_fields = ("name",) + list_display = ("name", "url", "monitor_all_organization_repos") + list_filter = ("monitor_all_organization_repos",) + search_fields = ("name", "url") ordering = ("name",) @@ -370,13 +372,25 @@ class SessionAdmin(DescriptiveSearchMixin, admin.ModelAdmin): preview_email.waitlist_email_action, preview_email.team_welcome_email_action, ] - list_display = ("title", "start_date", "end_date", "form_teams", "email_actions") + list_display = ( + "title", + "start_date", + "end_date", + "form_teams", + "collect_stats", + "email_actions", + ) @admin.display(description="Form Teams") def form_teams(self, obj): href = reverse("admin:session_form_teams", kwargs={"session_id": obj.id}) return mark_safe(f'Form Teams') + @admin.display(description="GitHub Stats") + def collect_stats(self, obj): + href = reverse("admin:session_collect_stats", args=[obj.id]) + return mark_safe(f'Collect Stats') + @admin.display(description="Email Actions") def email_actions(self, obj): actions = [ @@ -416,6 +430,11 @@ def get_urls(self): self.admin_site.admin_view(calculate_overlap_ajax), name="session_calculate_overlap", ), + path( + "/collect-stats/", + self.admin_site.admin_view(collect_stats_view), + name="session_collect_stats", + ), path( "/send-session-results/", self.admin_site.admin_view(send_session_results_view), diff --git a/home/factories.py b/home/factories.py index 507cfdf5..9a34b945 100644 --- a/home/factories.py +++ b/home/factories.py @@ -107,7 +107,7 @@ class Meta: model = Project name = factory.Sequence(lambda n: "Project %d" % n) - url = factory.Sequence(lambda n: "https://github.com/project-%d" % n) + url = factory.Sequence(lambda n: "https://github.com/test-org/project-%d" % n) class TeamFactory(factory.django.DjangoModelFactory): diff --git a/home/forms.py b/home/forms.py index 16a6b748..8733fb14 100644 --- a/home/forms.py +++ b/home/forms.py @@ -1,5 +1,6 @@ import csv import io +from datetime import date from django import forms from django.core import validators @@ -1204,6 +1205,138 @@ def save(self) -> bool: return is_accepted +class CompareAvailabilityForm(forms.Form): + """ + Form for handling compare availability querystring parameters. + + Validates session_id, user selection, and offset parameters. + Also determines which users the current user can select for comparison. + """ + + session = forms.ModelChoiceField( + queryset=Session.objects.all(), + required=False, + ) + users = forms.CharField(required=False) + offset = forms.FloatField(required=False, initial=0) + + def __init__(self, *args, user: CustomUser, **kwargs): + """ + Initialize form with the requesting user. + + Args: + user: The currently logged-in user making the request + """ + super().__init__(*args, **kwargs) + self.user = user + self._session = None + self._session_membership = None + + def clean_offset(self) -> float: + """Return offset value, defaulting to 0 if not provided or invalid.""" + offset = self.cleaned_data.get("offset") + return offset if offset is not None else 0.0 + + def clean_users(self) -> set[int]: + """Parse user IDs from form data, handling both comma-separated and multiple params.""" + result = set() + # Handle multiple params (from select multiple) and comma-separated values + values = ( + self.data.getlist("users") + if hasattr(self.data, "getlist") + else [self.data.get("users", "")] + ) + for value in values: + for uid in str(value).split(","): + if uid.strip().isdigit(): + result.add(int(uid.strip())) + return result + + def clean_session(self) -> Session | None: + if session := self.cleaned_data.get("session"): + self._session_membership = ( + SessionMembership.objects.for_session(session) + .for_user(self.user) + .first() + ) + self._session = session + return self._session + + def get_selectable_users(self) -> list[CustomUser]: + """ + Get users that the current user can select for comparison. + + Returns: + List of CustomUser objects the user can compare + """ + return list( + CustomUser.objects.for_comparing_availability( + user=self.user, + session=self._session, + session_membership=self._session_membership, + ) + ) + + def get_selected_users( + self, selectable_users: list[CustomUser] + ) -> list[CustomUser]: + """ + Get the users that are currently selected from the selectable users. + + Args: + selectable_users: List of users that can be selected + + Returns: + List of selected CustomUser objects + """ + if not self.cleaned_data["users"]: + return [] + return [u for u in selectable_users if u.id in self.cleaned_data["users"]] + + def get_offset_hours(self) -> float: + """Return the validated offset hours value.""" + return self.cleaned_data.get("offset", 0) + + +class CollectStatsForm(forms.Form): + """Form for collecting GitHub stats with date range selection.""" + + start_date = forms.DateField( + label=_("Start Date"), + widget=forms.DateInput(attrs={"type": "date"}), + ) + end_date = forms.DateField( + label=_("End Date"), + widget=forms.DateInput(attrs={"type": "date"}), + ) + + def clean_start_date(self) -> date: + start_date = self.cleaned_data["start_date"] + today = date.today() + if start_date > today: + return today + return start_date + + def clean_end_date(self) -> date: + end_date = self.cleaned_data["end_date"] + today = date.today() + if end_date > today: + return today + return end_date + + def clean(self) -> dict: + cleaned_data = super().clean() + start_date = cleaned_data.get("start_date") + end_date = cleaned_data.get("end_date") + + if start_date and end_date and start_date > end_date: + raise forms.ValidationError( + _("Start date must be before or equal to end date.") + ) + + return cleaned_data + + class TestimonialFormRenderer(DjangoTemplates): field_template_name = "home/testimonials/field.html" diff --git a/home/management/commands/__init__.py b/home/management/commands/__init__.py index e69de29b..6496d9c5 100644 --- a/home/management/commands/__init__.py +++ b/home/management/commands/__init__.py @@ -0,0 +1 @@ +"""Management commands package.""" diff --git a/home/migrations/0050_add_djangonauts_have_access_to_session.py b/home/migrations/0050_add_djangonauts_have_access_to_session.py index ab04b0c1..6ac6baba 100644 --- a/home/migrations/0050_add_djangonauts_have_access_to_session.py +++ b/home/migrations/0050_add_djangonauts_have_access_to_session.py @@ -15,7 +15,7 @@ class Migration(migrations.Migration): name="djangonauts_have_access", field=models.BooleanField( default=False, - help_text="Whether team detail pages are visible to Djangonauts. Automatically set to True when team welcome emails are sent. This will be ignored once session start date is in the past.", + help_text="Whether Djangonauts can access their team detail pages. Automatically set to True when team welcome emails are sent. This will be ignored once session start date is in the past.", ), ), ] diff --git a/home/migrations/0051_project_monitor_all_organization_repos_and_more.py b/home/migrations/0051_project_monitor_all_organization_repos_and_more.py new file mode 100644 index 00000000..b5dd8aa5 --- /dev/null +++ b/home/migrations/0051_project_monitor_all_organization_repos_and_more.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.12 on 2026-03-14 20:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("home", "0050_add_djangonauts_have_access_to_session"), + ] + + operations = [ + migrations.AddField( + model_name="project", + name="monitor_all_organization_repos", + field=models.BooleanField( + default=False, + help_text="When enabled, GitHub stats collection searches all source repositories in this GitHub organization instead of only this repository.", + ), + ), + ] diff --git a/home/models/session.py b/home/models/session.py index 971c2bee..6f955226 100644 --- a/home/models/session.py +++ b/home/models/session.py @@ -1,4 +1,5 @@ import datetime +from urllib.parse import urlparse from django.conf import settings from django.db import models @@ -34,10 +35,31 @@ class Project(models.Model): url = models.URLField( help_text=_("The URL for the project repository or website"), ) + monitor_all_organization_repos = models.BooleanField( + default=False, + help_text=_( + "When enabled, GitHub stats collection searches all source " + "repositories in this GitHub organization instead of only this " + "repository." + ), + ) class Meta: ordering = ["name"] + @property + def github_repo(self) -> tuple[str, str] | None: + """Return the GitHub org, repo pair from the configured project URL.""" + parsed_url = urlparse(self.url) + if parsed_url.netloc != "github.com": + return None + + path_parts = [part for part in parsed_url.path.split("/") if part] + if len(path_parts) < 2: + return None + + return path_parts[0], path_parts[1] + def __str__(self) -> str: return self.name diff --git a/home/services/__init__.py b/home/services/__init__.py new file mode 100644 index 00000000..5f556dd5 --- /dev/null +++ b/home/services/__init__.py @@ -0,0 +1 @@ +"""Services package for home app.""" diff --git a/home/services/github_stats.py b/home/services/github_stats.py new file mode 100644 index 00000000..79c460b4 --- /dev/null +++ b/home/services/github_stats.py @@ -0,0 +1,285 @@ +"""GitHub stats collection service for Djangonaut Space sessions.""" + +import logging +from dataclasses import dataclass, field +from datetime import date, datetime +from functools import cached_property + +from django.conf import settings +from github import Github +from urllib3.util.retry import Retry + +logger = logging.getLogger(__name__) + +STATE_OPEN = "open" +STATE_CLOSED = "closed" + + +@dataclass(frozen=True) +class Author: + github_username: str + name: str + + +@dataclass(frozen=True) +class TeamScope: + """A team's GitHub search scope paired with its members. + + One scope produces one set of queries (PRs created, PRs merged, issues) + per member. ``scope_term`` is a GitHub search qualifier such as + ``repo:owner/name`` or ``org:owner``. + """ + + scope_term: str + members: tuple[Author, ...] + label: str = "" + + +@dataclass +class PR: + title: str + number: int + url: str + author: Author + created_at: date + merged_at: date | None + state: str + repo: str + + @property + def is_open(self) -> bool: + return self.state == STATE_OPEN + + @property + def is_merged(self) -> bool: + return self.merged_at is not None + + @property + def is_closed(self) -> bool: + """Closed without merging.""" + return self.state == STATE_CLOSED and self.merged_at is None + + +@dataclass +class Issue: + title: str + number: int + url: str + author: Author + created_at: date + state: str + repo: str + + @property + def is_open(self) -> bool: + return self.state == STATE_OPEN + + +@dataclass +class TeamReport: + """PRs and issues collected for a single team scope.""" + + label: str + scope_term: str + prs: list[PR] = field(default_factory=list) + issues: list[Issue] = field(default_factory=list) + + @property + def merged_prs(self) -> list[PR]: + return [pr for pr in self.prs if pr.is_merged] + + @property + def closed_prs(self) -> list[PR]: + return [pr for pr in self.prs if pr.is_closed] + + @property + def open_prs(self) -> list[PR]: + return [pr for pr in self.prs if pr.is_open] + + @property + def open_issues(self) -> list[Issue]: + return [issue for issue in self.issues if issue.is_open] + + @property + def has_activity(self) -> bool: + return bool(self.prs or self.issues) + + +@dataclass +class StatsReport: + """Aggregates GitHub statistics for a date range, grouped by team.""" + + start_date: date + end_date: date + teams: list[TeamReport] = field(default_factory=list) + + @cached_property + def authors(self) -> set[Author]: + authors: set[Author] = set() + for team in self.teams: + authors.update(pr.author for pr in team.prs) + authors.update(issue.author for issue in team.issues) + return authors + + def count_open_prs(self) -> int: + return sum(len(t.open_prs) for t in self.teams) + + def count_merged_prs(self) -> int: + return sum(len(t.merged_prs) for t in self.teams) + + def count_closed_prs(self) -> int: + return sum(len(t.closed_prs) for t in self.teams) + + def count_open_issues(self) -> int: + return sum(len(t.open_issues) for t in self.teams) + + @property + def has_activity(self) -> bool: + return any(t.has_activity for t in self.teams) + + +class GitHubStatsCollector: + """Collects GitHub PR and Issue statistics using the GitHub Search API.""" + + def __init__(self, github_token: str | None = None): + token = github_token or settings.GITHUB_TOKEN + if not token: + raise ValueError("GitHub token is required. Set GITHUB_TOKEN in settings.") + + # PyGithub's default GithubRetry sleeps on rate-limit 403s; swap for a + # plain no-retry Retry so rate limiting surfaces as an exception + # instead of a silent wait. + self.github = Github(token, retry=Retry(total=0)) + logger.info("GitHubStatsCollector initialized (retries disabled)") + + def _to_date(self, dt: datetime | date | None) -> date | None: + """Convert a datetime to a date, passing through date and None unchanged.""" + if isinstance(dt, datetime): + return dt.date() + return dt + + def _get_repo_full_name(self, item) -> str: + """Parse ``owner/name`` from the search result's ``repository_url``. + + Using the URL avoids an extra API round-trip that ``item.repository.full_name`` + would cost (lazy attribute fetch). + """ + return item.repository_url.split("/repos/", 1)[1] + + def _item_kwargs(self, item, repo_full_name: str, author: Author) -> dict: + """Fields shared by ``PR`` and ``Issue`` constructed from a search result.""" + return dict( + title=item.title, + number=item.number, + url=item.html_url, + author=author, + created_at=self._to_date(item.created_at), + state=STATE_OPEN if item.state == STATE_OPEN else STATE_CLOSED, + repo=repo_full_name, + ) + + def _pr_from_search_result(self, item, repo_full_name: str, author: Author) -> PR: + return PR( + **self._item_kwargs(item, repo_full_name, author), + merged_at=self._to_date(item.pull_request.merged_at), + ) + + def _issue_from_search_result( + self, item, repo_full_name: str, author: Author + ) -> Issue: + return Issue(**self._item_kwargs(item, repo_full_name, author)) + + def _collect_scope_prs( + self, + scope: TeamScope, + start_date: date, + end_date: date, + team_report: TeamReport, + ) -> None: + """Fetch PRs for one team scope into its ``TeamReport``. + + One query per (member, qualifier) — GitHub Search does NOT support + OR'd ``author:`` clauses (returns 422). Both ``created:`` and + ``merged:`` are queried so PRs merged inside the window are counted + even when opened earlier; results are deduplicated by ``(repo, + number)`` across the two. + """ + seen: set[tuple[str, int]] = set() + for member in scope.members: + for qualifier in ("created", "merged"): + query = ( + f"{scope.scope_term} author:{member.github_username} " + f"type:pr {qualifier}:{start_date}..{end_date}" + ) + logger.info("Searching PRs (%s): %s", qualifier, query) + for item in self.github.search_issues(query): + repo_full_name = self._get_repo_full_name(item) + key = (repo_full_name.lower(), item.number) + if key in seen: + continue + seen.add(key) + team_report.prs.append( + self._pr_from_search_result(item, repo_full_name, member) + ) + + def _collect_scope_issues( + self, + scope: TeamScope, + start_date: date, + end_date: date, + team_report: TeamReport, + ) -> None: + """Fetch issues for one team scope into its ``TeamReport``.""" + for member in scope.members: + query = ( + f"{scope.scope_term} author:{member.github_username} " + f"type:issue created:{start_date}..{end_date}" + ) + logger.info("Searching issues: %s", query) + for item in self.github.search_issues(query): + repo_full_name = self._get_repo_full_name(item) + team_report.issues.append( + self._issue_from_search_result(item, repo_full_name, member) + ) + + def collect_all_stats( + self, + scopes: list[TeamScope], + start_date: date, + end_date: date, + ) -> StatsReport: + """Collect PR and issue stats across a list of team scopes. + + Each scope pairs one GitHub search scope (``repo:`` or ``org:``) + with the members whose contributions should be counted there, so + queries are both tight (no cross-team noise) and results stay + naturally grouped by team in the returned report. + """ + logger.info( + "Starting stats collection across %d team scopes (%s to %s)", + len(scopes), + start_date, + end_date, + ) + + teams: list[TeamReport] = [] + for scope in scopes: + logger.debug( + "Scope %s (%s) with %d members", + scope.label or scope.scope_term, + scope.scope_term, + len(scope.members), + ) + team_report = TeamReport(label=scope.label, scope_term=scope.scope_term) + self._collect_scope_prs(scope, start_date, end_date, team_report) + self._collect_scope_issues(scope, start_date, end_date, team_report) + teams.append(team_report) + + report = StatsReport(start_date=start_date, end_date=end_date, teams=teams) + logger.info( + "Collection complete: %d PRs, %d issues across %d teams", + sum(len(t.prs) for t in teams), + sum(len(t.issues) for t in teams), + len(teams), + ) + return report diff --git a/home/tasks/testimonial_notifications.py b/home/tasks/testimonial_notifications.py index efd4d3e8..ef06fc81 100644 --- a/home/tasks/testimonial_notifications.py +++ b/home/tasks/testimonial_notifications.py @@ -5,8 +5,6 @@ testimonials are created or updated, allowing for review and approval. """ -from typing import Optional - from django.conf import settings from django.contrib.auth import get_user_model from django.urls import reverse diff --git a/home/templates/admin/collect_stats_form.html b/home/templates/admin/collect_stats_form.html new file mode 100644 index 00000000..8dbdfb0e --- /dev/null +++ b/home/templates/admin/collect_stats_form.html @@ -0,0 +1,84 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block extrahead %}{{ block.super }} + +{% endblock %} + +{% block extrastyle %}{{ block.super }} + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+

Collect GitHub Stats for "{{ session.title }}"

+ +
+

{% trans "Configuration" %}

+

+ {% blocktrans with team_count=scopes|length %} + Tracking {{ djangonaut_count }} Djangonauts across {{ team_count }} teams. + {% endblocktrans %} +

+ +
+ {% csrf_token %} + + {% if form.non_field_errors %} +

+ {% for error in form.non_field_errors %} + {{ error }} + {% endfor %} +

+ {% endif %} + +
+
+ {{ form.start_date.errors }} + + {{ form.start_date }} +
+ +
+ {{ form.end_date.errors }} + + {{ form.end_date }} +
+ +
+ +
+ {% for scope in scopes %} +
+ {{ scope.label|default:scope.scope_term }} +
+ {{ scope.scope_term }} +
+
+ {% for member in scope.members %} + {{ member.name }} ({{ member.github_username }}){% if not forloop.last %}, {% endif %} + {% endfor %} +
+
+ {% endfor %} +
+
+
+ + +
+
+
+{% endblock %} diff --git a/home/templates/admin/collect_stats_results.html b/home/templates/admin/collect_stats_results.html new file mode 100644 index 00000000..2c6d0845 --- /dev/null +++ b/home/templates/admin/collect_stats_results.html @@ -0,0 +1,328 @@ +{% extends "admin/base_site.html" %} +{% load i18n static %} + +{% block extrastyle %}{{ block.super }} + +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +
+

Stats Results for "{{ session.title }}"

+ +
+

Overview {{ report.start_date }} → {{ report.end_date }}

+
+
Open PRs{{ report.count_open_prs }}
+
Merged PRs{{ report.count_merged_prs }}
+
Closed PRs{{ report.count_closed_prs }}
+
Issues{{ report.count_open_issues }}
+
+ {% with authors=report.authors %} + {% if authors %} +
+ Djangonauts: + {% for author in authors %} + {{ author.name }} @{{ author.github_username }}{% if not forloop.last %}, {% endif %} + {% endfor %} +
+ {% endif %} + {% endwith %} +
+ + {% for team in report.teams %} + {% if team.has_activity %} +
+

{{ team.label|default:"Unassigned" }}

+
+ {{ team.open_prs|length }} open PRs · {{ team.merged_prs|length }} merged PRs · + {{ team.closed_prs|length }} closed PRs · {{ team.open_issues|length }} issues +
+ + {% if team.merged_prs %} +
+

🎉 Merged Pull Requests

+
    + {% for pr in team.merged_prs %} +
  • + {{ pr.title }} +
    + {{ pr.repo }} + by {{ pr.author.name }} @{{ pr.author.github_username }} + on {{ pr.merged_at }} +
    + View on GitHub → +
  • + {% endfor %} +
+
+ {% endif %} + + {% if team.closed_prs %} +
+

🚧 Closed Pull Requests

+
    + {% for pr in team.closed_prs %} +
  • + {{ pr.title }} +
    + {{ pr.repo }} + by {{ pr.author.name }} @{{ pr.author.github_username }} + created {{ pr.created_at }} +
    + View on GitHub → +
  • + {% endfor %} +
+
+ {% endif %} + + {% if team.open_prs %} +
+

✨ Open Pull Requests

+
    + {% for pr in team.open_prs %} +
  • + {{ pr.title }} +
    + {{ pr.repo }} + by {{ pr.author.name }} @{{ pr.author.github_username }} + created {{ pr.created_at }} +
    + View on GitHub → +
  • + {% endfor %} +
+
+ {% endif %} + + {% if team.open_issues %} +
+

✏️ Issues

+
    + {% for issue in team.open_issues %} +
  • + {{ issue.title }} +
    + {{ issue.repo }} + by {{ issue.author.name }} @{{ issue.author.github_username }} + created {{ issue.created_at }} +
    + View on GitHub → +
  • + {% endfor %} +
+
+ {% endif %} +
+ {% endif %} + {% endfor %} + + {% if not report.has_activity %} +
+

No GitHub activity found

+

Try selecting a different date range.

+
+ {% endif %} + + +
+{% endblock %} diff --git a/home/tests/test_forms.py b/home/tests/test_forms.py index b90fed2c..8cd31270 100644 --- a/home/tests/test_forms.py +++ b/home/tests/test_forms.py @@ -1,16 +1,17 @@ import csv import io -from datetime import timedelta +from datetime import date, timedelta from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile -from django.test import TestCase +from django.test import SimpleTestCase, TestCase from django.utils import timezone from accounts.factories import UserFactory from home.factories import QuestionFactory, SessionFactory from home.factories import SurveyFactory from home.factories import UserSurveyResponseFactory +from home.forms import CollectStatsForm from home.forms import CreateUserSurveyResponseForm from home.forms import EditUserSurveyResponseForm from home.forms import SurveyCSVExportForm, SurveyCSVImportForm @@ -286,6 +287,23 @@ def test_edit_form_updates_timestamp(self): ) +class CollectStatsFormTests(SimpleTestCase): + def test_future_dates_are_normalized_to_today(self): + today = date.today() + future_date = today + timedelta(days=1) + + form = CollectStatsForm( + data={ + "start_date": future_date.isoformat(), + "end_date": future_date.isoformat(), + } + ) + + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data["start_date"], today) + self.assertEqual(form.cleaned_data["end_date"], today) + + class SurveyCSVExportFormTests(TestCase): """Test the SurveyCSVExportForm CSV generation methods.""" diff --git a/home/tests/test_github_stats.py b/home/tests/test_github_stats.py new file mode 100644 index 00000000..5480ec41 --- /dev/null +++ b/home/tests/test_github_stats.py @@ -0,0 +1,798 @@ +from datetime import date, datetime +from unittest.mock import Mock, patch + +from django.contrib.auth import get_user_model +from django.contrib.messages import get_messages +from django.test import Client, SimpleTestCase, TestCase, override_settings +from django.urls import reverse +from github import GithubException + +from accounts.factories import UserFactory +from accounts.models import UserProfile +from home.factories import ( + ProjectFactory, + SessionFactory, + SessionMembershipFactory, + TeamFactory, +) +from home.models.session import SessionMembership +from home.services.github_stats import ( + Author, + GitHubStatsCollector, + Issue, + PR, + StatsReport, + TeamReport, + TeamScope, +) + +User = get_user_model() + + +def _build_mock_pr( + *, + number: int, + login: str = "testuser", + created_at=None, + merged_at=None, + state: str = "open", + repo: str = "test-org/test-repo", +): + """Build a mock search-result PR item suitable for ``collect_all_stats``.""" + mock_user = Mock() + mock_user.login = login + + mock_pr = Mock() + mock_pr.title = f"PR {number}" + mock_pr.number = number + mock_pr.html_url = f"https://github.com/{repo}/pull/{number}" + mock_pr.user = mock_user + mock_pr.created_at = created_at or date(2024, 1, 15) + mock_pr.state = state + mock_pr.repository_url = f"https://api.github.com/repos/{repo}" + mock_pr.pull_request = Mock(merged_at=merged_at) + return mock_pr + + +def _build_mock_issue( + *, + number: int, + login: str = "testuser", + created_at=None, + state: str = "open", + repo: str = "test-org/test-repo", +): + mock_user = Mock() + mock_user.login = login + + mock_issue = Mock() + mock_issue.title = f"Issue {number}" + mock_issue.number = number + mock_issue.html_url = f"https://github.com/{repo}/issues/{number}" + mock_issue.user = mock_user + mock_issue.created_at = created_at or date(2024, 1, 20) + mock_issue.state = state + mock_issue.repository_url = f"https://api.github.com/repos/{repo}" + return mock_issue + + +class GitHubStatsCollectorTests(SimpleTestCase): + def setUp(self): + self.mock_token = "ghp_test_token" + self.author = Author(github_username="testuser", name="Test User") + self.scope = TeamScope( + scope_term="repo:test-org/test-repo", + members=(self.author,), + label="Team Alpha - Django", + ) + + @patch("home.services.github_stats.Github") + def test_init_with_token(self, mock_github_class): + collector = GitHubStatsCollector(self.mock_token) + mock_github_class.assert_called_once() + self.assertEqual(mock_github_class.call_args.args[0], self.mock_token) + self.assertIsNotNone(collector.github) + + @patch("home.services.github_stats.Github") + def test_init_with_settings_token(self, mock_github_class): + with override_settings(GITHUB_TOKEN="settings_token"): + GitHubStatsCollector() + mock_github_class.assert_called_once() + self.assertEqual(mock_github_class.call_args.args[0], "settings_token") + + @patch("home.services.github_stats.Github") + def test_init_disables_retries_for_loud_rate_limit_failure(self, mock_github_class): + """A total=0 urllib3 Retry is passed so rate-limit 403s raise instead of sleep.""" + GitHubStatsCollector(self.mock_token) + retry = mock_github_class.call_args.kwargs["retry"] + self.assertEqual(retry.total, 0) + + @patch("home.services.github_stats.Github") + def test_init_without_token_raises_error(self, mock_github_class): + with override_settings(GITHUB_TOKEN=None): + with self.assertRaises(ValueError) as context: + GitHubStatsCollector() + self.assertIn("GitHub token is required", str(context.exception)) + + @patch("home.services.github_stats.Github") + def test_collect_all_stats_returns_pr_and_issue(self, mock_github_class): + mock_pr = _build_mock_pr( + number=123, + login="testuser", + created_at=date(2024, 1, 15), + merged_at=datetime(2024, 1, 16, 12, 0, 0), + state="open", + ) + mock_issue = _build_mock_issue( + number=456, login="testuser", created_at=date(2024, 1, 20) + ) + + mock_github_instance = mock_github_class.return_value + # created-PR query, merged-PR query (empty), issue query + mock_github_instance.search_issues.side_effect = [ + [mock_pr], + [], + [mock_issue], + ] + + collector = GitHubStatsCollector(self.mock_token) + report = collector.collect_all_stats( + scopes=[self.scope], + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 31), + ) + + self.assertIsInstance(report, StatsReport) + self.assertEqual(len(report.teams), 1) + team = report.teams[0] + self.assertEqual(team.label, "Team Alpha - Django") + self.assertEqual(team.scope_term, "repo:test-org/test-repo") + self.assertEqual(len(team.prs), 1) + self.assertEqual(len(team.issues), 1) + self.assertEqual(team.prs[0].merged_at, date(2024, 1, 16)) + self.assertEqual(team.prs[0].repo, "test-org/test-repo") + # Author carries the real display name from the scope, not the login. + self.assertEqual(team.prs[0].author.name, "Test User") + self.assertEqual(team.issues[0].author.name, "Test User") + + @patch("home.services.github_stats.Github") + def test_collect_all_stats_runs_created_merged_and_issue_queries( + self, mock_github_class + ): + mock_github_instance = mock_github_class.return_value + mock_github_instance.search_issues.side_effect = [[], [], []] + + collector = GitHubStatsCollector(self.mock_token) + collector.collect_all_stats( + scopes=[self.scope], + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 31), + ) + + self.assertEqual(mock_github_instance.search_issues.call_count, 3) + created_pr_query = mock_github_instance.search_issues.call_args_list[0].args[0] + merged_pr_query = mock_github_instance.search_issues.call_args_list[1].args[0] + issue_query = mock_github_instance.search_issues.call_args_list[2].args[0] + + self.assertIn("repo:test-org/test-repo", created_pr_query) + self.assertIn("author:testuser", created_pr_query) + self.assertIn("type:pr", created_pr_query) + self.assertIn("created:2024-01-01..2024-01-31", created_pr_query) + + self.assertIn("repo:test-org/test-repo", merged_pr_query) + self.assertIn("author:testuser", merged_pr_query) + self.assertIn("type:pr", merged_pr_query) + self.assertIn("merged:2024-01-01..2024-01-31", merged_pr_query) + + self.assertIn("repo:test-org/test-repo", issue_query) + self.assertIn("type:issue", issue_query) + self.assertIn("created:2024-01-01..2024-01-31", issue_query) + + @patch("home.services.github_stats.Github") + def test_collect_all_stats_deduplicates_pr_across_created_and_merged( + self, mock_github_class + ): + """A PR returned by both the ``created:`` and ``merged:`` query is counted once.""" + mock_pr = _build_mock_pr( + number=42, + login="testuser", + created_at=date(2024, 1, 15), + merged_at=datetime(2024, 1, 18, 12, 0, 0), + state="closed", + ) + + mock_github_instance = mock_github_class.return_value + # Same PR returned by the created query AND the merged query, plus empty issues. + mock_github_instance.search_issues.side_effect = [[mock_pr], [mock_pr], []] + + collector = GitHubStatsCollector(self.mock_token) + report = collector.collect_all_stats( + scopes=[self.scope], + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 31), + ) + + prs = report.teams[0].prs + self.assertEqual(len(prs), 1) + self.assertEqual(prs[0].number, 42) + self.assertTrue(prs[0].is_merged) + + @patch("home.services.github_stats.Github") + def test_collect_all_stats_captures_pr_merged_but_not_created_in_window( + self, mock_github_class + ): + """PRs created before the window but merged inside it are still captured.""" + mock_pr = _build_mock_pr( + number=7, + login="testuser", + created_at=date(2023, 12, 28), + merged_at=datetime(2024, 1, 5, 10, 0, 0), + state="closed", + ) + + mock_github_instance = mock_github_class.return_value + # The created: query doesn't find it; the merged: query does. + mock_github_instance.search_issues.side_effect = [[], [mock_pr], []] + + collector = GitHubStatsCollector(self.mock_token) + report = collector.collect_all_stats( + scopes=[self.scope], + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 31), + ) + + prs = report.teams[0].prs + self.assertEqual(len(prs), 1) + self.assertEqual(prs[0].number, 7) + self.assertTrue(prs[0].is_merged) + + @patch("home.services.github_stats.Github") + def test_collect_all_stats_runs_one_query_per_member_per_qualifier( + self, mock_github_class + ): + """GitHub Search rejects OR'd author clauses (422), so we query per member.""" + scope = TeamScope( + scope_term="repo:test-org/test-repo", + members=( + Author(github_username="alice", name="Alice A"), + Author(github_username="bob", name="Bob B"), + ), + label="Team Alpha", + ) + mock_github_instance = mock_github_class.return_value + mock_github_instance.search_issues.return_value = [] + + collector = GitHubStatsCollector(self.mock_token) + collector.collect_all_stats( + scopes=[scope], + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 31), + ) + + # 2 members × (created PR + merged PR + issue) = 6 queries. + self.assertEqual(mock_github_instance.search_issues.call_count, 6) + queries = [c.args[0] for c in mock_github_instance.search_issues.call_args_list] + for q in queries: + self.assertNotIn(" OR ", q) + self.assertTrue(any("author:alice" in q for q in queries)) + self.assertTrue(any("author:bob" in q for q in queries)) + + @patch("home.services.github_stats.Github") + def test_collect_all_stats_runs_per_scope(self, mock_github_class): + """Two scopes run their own created+merged+issue query sets.""" + scope_a = TeamScope( + scope_term="repo:org-a/repo-a", + members=(Author(github_username="alice", name="Alice A"),), + ) + scope_b = TeamScope( + scope_term="org:org-b", + members=(Author(github_username="bob", name="Bob B"),), + ) + + mock_github_instance = mock_github_class.return_value + mock_github_instance.search_issues.return_value = [] + + collector = GitHubStatsCollector(self.mock_token) + collector.collect_all_stats( + scopes=[scope_a, scope_b], + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 31), + ) + + # 3 queries per scope (created PR, merged PR, issues) × 2 scopes = 6. + self.assertEqual(mock_github_instance.search_issues.call_count, 6) + queries = [c.args[0] for c in mock_github_instance.search_issues.call_args_list] + self.assertTrue( + any("repo:org-a/repo-a" in q and "author:alice" in q for q in queries) + ) + self.assertTrue(any("org:org-b" in q and "author:bob" in q for q in queries)) + + def test_to_date(self): + """Test _to_date helper method.""" + collector = GitHubStatsCollector(self.mock_token) + + # Test datetime conversion + dt = datetime(2024, 1, 15, 12, 0, 0) + self.assertEqual(collector._to_date(dt), date(2024, 1, 15)) + + # Test date pass-through + d = date(2024, 1, 15) + self.assertEqual(collector._to_date(d), d) + + # Test None + self.assertIsNone(collector._to_date(None)) + + +class DataClassesTests(SimpleTestCase): + def setUp(self): + self.author = Author(github_username="test", name="Test") + + def test_pr_properties(self): + """Test PR property methods (is_open, is_merged, is_closed).""" + # Open PR + pr_open = PR( + title="t", + number=1, + url="u", + author=self.author, + created_at=date(2024, 1, 1), + merged_at=None, + state="open", + repo="r", + ) + self.assertTrue(pr_open.is_open) + self.assertFalse(pr_open.is_merged) + self.assertFalse(pr_open.is_closed) + + # Merged PR + pr_merged = PR( + title="t", + number=1, + url="u", + author=self.author, + created_at=date(2024, 1, 1), + merged_at=date(2024, 1, 2), + state="closed", + repo="r", + ) + self.assertFalse(pr_merged.is_open) + self.assertTrue(pr_merged.is_merged) + self.assertFalse(pr_merged.is_closed) + + # Closed PR + pr_closed = PR( + title="t", + number=1, + url="u", + author=self.author, + created_at=date(2024, 1, 1), + merged_at=None, + state="closed", + repo="r", + ) + self.assertFalse(pr_closed.is_open) + self.assertFalse(pr_closed.is_merged) + self.assertTrue(pr_closed.is_closed) + + def test_issue_properties(self): + """Test Issue property methods.""" + issue = Issue( + title="t", + number=1, + url="u", + author=self.author, + created_at=date(2024, 1, 1), + state="open", + repo="r", + ) + self.assertTrue(issue.is_open) + + issue_closed = Issue( + title="t", + number=1, + url="u", + author=self.author, + created_at=date(2024, 1, 1), + state="closed", + repo="r", + ) + self.assertFalse(issue_closed.is_open) + + +class StatsReportTests(SimpleTestCase): + def setUp(self): + self.author1 = Author(github_username="user1", name="User One") + self.author2 = Author(github_username="user2", name="User Two") + + self.pr_open = PR( + title="Open PR", + number=1, + url="https://github.com/org/repo/pull/1", + author=self.author1, + created_at=date(2024, 1, 10), + merged_at=None, + state="open", + repo="org/repo", + ) + + self.pr_merged = PR( + title="Merged PR", + number=2, + url="https://github.com/org/repo/pull/2", + author=self.author2, + created_at=date(2024, 1, 5), + merged_at=date(2024, 1, 15), + state="closed", + repo="org/repo", + ) + + self.pr_closed = PR( + title="Closed PR", + number=3, + url="https://github.com/org/repo/pull/3", + author=self.author1, + created_at=date(2024, 1, 8), + merged_at=None, + state="closed", + repo="org/repo", + ) + + self.issue_open = Issue( + title="Open Issue", + number=10, + url="https://github.com/org/repo/issues/10", + author=self.author1, + created_at=date(2024, 1, 12), + state="open", + repo="org/repo", + ) + + self.team_report = TeamReport( + label="Team A - Project X", + scope_term="repo:org/repo", + prs=[self.pr_open, self.pr_merged, self.pr_closed], + issues=[self.issue_open], + ) + self.report = StatsReport( + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 31), + teams=[self.team_report], + ) + + def test_team_open_prs(self): + self.assertEqual([pr.title for pr in self.team_report.open_prs], ["Open PR"]) + + def test_team_merged_prs(self): + self.assertEqual( + [pr.title for pr in self.team_report.merged_prs], ["Merged PR"] + ) + + def test_team_closed_prs(self): + self.assertEqual( + [pr.title for pr in self.team_report.closed_prs], ["Closed PR"] + ) + + def test_team_open_issues(self): + self.assertEqual( + [i.title for i in self.team_report.open_issues], ["Open Issue"] + ) + + def test_authors(self): + authors = self.report.authors + author_names = {author.name for author in authors} + self.assertEqual(author_names, {"User One", "User Two"}) + + def test_count_methods(self): + self.assertEqual(self.report.count_open_prs(), 1) + self.assertEqual(self.report.count_merged_prs(), 1) + self.assertEqual(self.report.count_closed_prs(), 1) + self.assertEqual(self.report.count_open_issues(), 1) + + def test_count_methods_sum_across_teams(self): + second_team = TeamReport( + label="Team B", + scope_term="repo:org/other", + prs=[self.pr_open], + ) + report = StatsReport( + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 31), + teams=[self.team_report, second_team], + ) + self.assertEqual(report.count_open_prs(), 2) + self.assertEqual(report.count_merged_prs(), 1) + + def test_has_activity(self): + self.assertTrue(self.report.has_activity) + empty = StatsReport( + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 31), + teams=[TeamReport(label="Empty", scope_term="repo:o/r")], + ) + self.assertFalse(empty.has_activity) + + def test_pr_is_closed(self): + """Test that is_closed property correctly identifies closed PRs.""" + self.assertTrue(self.pr_closed.is_closed) + self.assertFalse(self.pr_merged.is_closed) # merged PR should not be closed + self.assertFalse(self.pr_open.is_closed) # open PR should not be closed + + +class CollectStatsViewIntegrationTests(TestCase): + """Integration tests for the collect_stats_view admin interface.""" + + def setUp(self): + self.staff_user = UserFactory.create(is_staff=True, is_superuser=True) + self.client = Client() + self.client.force_login(self.staff_user) + + self.session = SessionFactory.create(title="Test Session") + self.project = ProjectFactory.create( + name="Django", + url="https://github.com/test-org/test-repo", + ) + self.session.available_projects.add(self.project) + + self.team = TeamFactory.create( + session=self.session, project=self.project, name="Team Alpha" + ) + + self.djangonaut1 = UserFactory.create(first_name="Jane", last_name="Doe") + UserProfile.objects.filter(user=self.djangonaut1).update( + github_username="djangonaut1" + ) + SessionMembershipFactory.create( + session=self.session, + user=self.djangonaut1, + team=self.team, + role=SessionMembership.DJANGONAUT, + ) + + self.djangonaut2 = UserFactory.create(first_name="John", last_name="Smith") + UserProfile.objects.filter(user=self.djangonaut2).update( + github_username="djangonaut2" + ) + SessionMembershipFactory.create( + session=self.session, + user=self.djangonaut2, + team=self.team, + role=SessionMembership.DJANGONAUT, + ) + + self.url = reverse( + "admin:session_collect_stats", kwargs={"session_id": self.session.id} + ) + + @override_settings(GITHUB_TOKEN="test_token") + @patch("home.views.sessions.GitHubStatsCollector") + def test_displays_mixed_pr_states(self, mock_collector_class): + """Verify view correctly displays open, merged, and closed PRs.""" + mock_collector = Mock() + mock_collector_class.return_value = mock_collector + mock_collector.collect_all_stats.return_value = StatsReport( + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 31), + teams=[ + TeamReport( + label="Team Alpha - Django", + scope_term="repo:test-org/test-repo", + prs=[ + PR( + title="Open PR", + number=1, + url="https://github.com/test-org/test-repo/pull/1", + author=Author( + github_username="djangonaut1", name="Jane Doe" + ), + created_at=date(2024, 1, 15), + merged_at=None, + state="open", + repo="test-org/test-repo", + ), + PR( + title="Merged PR", + number=2, + url="https://github.com/test-org/test-repo/pull/2", + author=Author( + github_username="djangonaut2", name="John Smith" + ), + created_at=date(2024, 1, 10), + merged_at=date(2024, 1, 20), + state="closed", + repo="test-org/test-repo", + ), + PR( + title="Closed PR", + number=3, + url="https://github.com/test-org/test-repo/pull/3", + author=Author( + github_username="djangonaut1", name="Jane Doe" + ), + created_at=date(2024, 1, 12), + merged_at=None, + state="closed", + repo="test-org/test-repo", + ), + ], + issues=[ + Issue( + title="Test Issue", + number=10, + url="https://github.com/test-org/test-repo/issues/10", + author=Author( + github_username="djangonaut1", name="Jane Doe" + ), + created_at=date(2024, 1, 18), + state="open", + repo="test-org/test-repo", + ), + ], + ), + ], + ) + + response = self.client.post( + self.url, + {"start_date": "2024-01-01", "end_date": "2024-01-31"}, + ) + + self.assertEqual(response.status_code, 200) + content = response.content.decode() + + self.assertIn("🎉 Merged Pull Requests", content) + self.assertIn("🚧 Closed Pull Requests", content) + self.assertIn("✨ Open Pull Requests", content) + self.assertIn("✏️ Issues", content) + + # The collector was called once with a list of TeamScopes derived from + # the session's teams, not a flat repo/username list. + mock_collector.collect_all_stats.assert_called_once() + call_kwargs = mock_collector.collect_all_stats.call_args.kwargs + self.assertEqual(call_kwargs["start_date"], date(2024, 1, 1)) + self.assertEqual(call_kwargs["end_date"], date(2024, 1, 31)) + scopes = call_kwargs["scopes"] + self.assertEqual(len(scopes), 1) + scope = scopes[0] + self.assertEqual(scope.scope_term, "repo:test-org/test-repo") + self.assertEqual( + {m.github_username for m in scope.members}, + {"djangonaut1", "djangonaut2"}, + ) + self.assertEqual( + {m.name for m in scope.members}, + {"Jane Doe", "John Smith"}, + ) + + @override_settings(GITHUB_TOKEN="test_token") + @patch("home.views.sessions.GitHubStatsCollector") + def test_uses_org_scope_when_project_monitors_whole_org(self, mock_collector_class): + self.project.monitor_all_organization_repos = True + self.project.save() + + mock_collector = Mock() + mock_collector_class.return_value = mock_collector + mock_collector.collect_all_stats.return_value = StatsReport( + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 31), + teams=[], + ) + + self.client.post( + self.url, + {"start_date": "2024-01-01", "end_date": "2024-01-31"}, + ) + + scope = mock_collector.collect_all_stats.call_args.kwargs["scopes"][0] + self.assertEqual(scope.scope_term, "org:test-org") + + @override_settings(GITHUB_TOKEN="test_token") + @patch("home.views.sessions.GitHubStatsCollector") + def test_hides_empty_sections(self, mock_collector_class): + """Verify sections without data are not displayed.""" + mock_collector = Mock() + mock_collector_class.return_value = mock_collector + mock_collector.collect_all_stats.return_value = StatsReport( + start_date=date(2024, 1, 1), + end_date=date(2024, 1, 31), + teams=[ + TeamReport( + label="Team Alpha - Django", + scope_term="repo:test-org/test-repo", + prs=[ + PR( + title="Closed PR", + number=1, + url="https://github.com/test-org/test-repo/pull/1", + author=Author( + github_username="djangonaut1", name="Jane Doe" + ), + created_at=date(2024, 1, 15), + merged_at=None, + state="closed", + repo="test-org/test-repo", + ), + ], + ), + ], + ) + + response = self.client.post( + self.url, + {"start_date": "2024-01-01", "end_date": "2024-01-31"}, + ) + + self.assertEqual(response.status_code, 200) + content = response.content.decode() + + self.assertIn("🚧 Closed Pull Requests", content) + self.assertNotIn("🎉 Merged Pull Requests", content) + self.assertNotIn("✨ Open Pull Requests", content) + self.assertNotIn("✏️ Issues", content) + + @override_settings(GITHUB_TOKEN="test_token") + def test_form_display(self): + """GET request shows the date selection form.""" + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + content = response.content.decode() + self.assertIn("start_date", content) + self.assertIn("end_date", content) + self.assertIn(self.session.title, content) + + @override_settings(GITHUB_TOKEN="test_token") + def test_redirects_when_session_has_no_teams_with_github_projects(self): + """Redirects with error when no team has a GitHub-backed project.""" + self.session.teams.all().delete() + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 302) + messages = list(get_messages(response.wsgi_request)) + self.assertIn( + "No teams with GitHub projects", + str(messages[0]), + ) + + @override_settings(GITHUB_TOKEN="test_token") + @patch("home.views.sessions.GitHubStatsCollector") + def test_github_api_error_redirects_with_message(self, mock_collector_class): + """Redirects with error message when GitHub API raises an exception.""" + mock_collector = Mock() + mock_collector_class.return_value = mock_collector + mock_collector.collect_all_stats.side_effect = GithubException( + 500, "API error", None + ) + + response = self.client.post( + self.url, + {"start_date": "2024-01-01", "end_date": "2024-01-31"}, + ) + + self.assertEqual(response.status_code, 302) + msgs = list(get_messages(response.wsgi_request)) + self.assertIn("GitHub API error", str(msgs[0])) + + @override_settings(GITHUB_TOKEN="test_token") + def test_invalid_form_redisplays_form(self): + """POST with invalid dates re-renders the form with errors.""" + response = self.client.post( + self.url, + {"start_date": "", "end_date": ""}, + ) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, "start_date") + + @override_settings(GITHUB_TOKEN="test_token") + def test_redirects_when_no_github_usernames(self): + """Redirects with message when no team Djangonauts have GitHub usernames.""" + UserProfile.objects.filter( + user__in=[self.djangonaut1, self.djangonaut2] + ).update(github_username="") + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 302) + messages = list(get_messages(response.wsgi_request)) + self.assertIn("No teams with GitHub projects", str(messages[0])) diff --git a/home/tests/test_models.py b/home/tests/test_models.py index 4e49e1e1..12f4ace0 100644 --- a/home/tests/test_models.py +++ b/home/tests/test_models.py @@ -475,6 +475,27 @@ def test_send_notifications_include_response_url(self): self.assertIn(response.get_full_url(), mail.outbox[0].body) +class ProjectTests(TestCase): + """Tests for Project GitHub metadata helpers.""" + + def test_github_repo_returns_owner_and_repo(self): + project = ProjectFactory.create( + url="https://github.com/django/django/contributors" + ) + + self.assertEqual(project.github_repo, ("django", "django")) + + def test_github_repo_returns_none_for_non_github_url(self): + project = ProjectFactory.create(url="https://www.djangoproject.com/") + + self.assertIsNone(project.github_repo) + + def test_github_repo_returns_none_for_short_github_path(self): + project = ProjectFactory.create(url="https://github.com/django") + + self.assertIsNone(project.github_repo) + + class SessionMembershipTests(TestCase): """Tests for SessionMembership model.""" diff --git a/home/views/compare_availability.py b/home/views/compare_availability.py index 026f84c7..b2fb208f 100644 --- a/home/views/compare_availability.py +++ b/home/views/compare_availability.py @@ -3,11 +3,11 @@ from dataclasses import asdict, dataclass from datetime import datetime -from django import forms from django.contrib.auth.decorators import login_required from django.shortcuts import render from accounts.models import CustomUser +from home.forms import CompareAvailabilityForm from home.availability import ( convert_slot_with_offset, format_slot_as_time, @@ -16,7 +16,7 @@ ) from home.models import Session, SessionMembership -slotAvailabilities = dict[str, list[int]] +SlotAvailabilities = dict[str, list[int]] @dataclass @@ -71,7 +71,7 @@ def build_grid_data( selected_users: list[CustomUser], user_slots: dict[int, set[float]], offset_hours: float = 0, -) -> tuple[list[GridRow], slotAvailabilities]: +) -> tuple[list[GridRow], SlotAvailabilities]: """ Build grid rows and slot availability mapping. @@ -80,7 +80,7 @@ def build_grid_data( contains availability data for each slot for Alpine.js """ rows = [] - slot_availabilities: slotAvailabilities = {} + slot_availabilities: SlotAvailabilities = {} total_count = len(selected_users) for hour in range(24): @@ -124,99 +124,6 @@ def build_grid_data( return rows, slot_availabilities -class CompareAvailabilityForm(forms.Form): - """ - Form for handling compare availability querystring parameters. - - Validates session_id, user selection, and offset parameters. - Also determines which users the current user can select for comparison. - """ - - session = forms.ModelChoiceField( - queryset=Session.objects.all(), - required=False, - ) - users = forms.CharField(required=False) - offset = forms.FloatField(required=False, initial=0) - - def __init__(self, *args, user: CustomUser, **kwargs): - """ - Initialize form with the requesting user. - - Args: - user: The currently logged-in user making the request - """ - super().__init__(*args, **kwargs) - self.user = user - self._session = None - self._session_membership = None - - def clean_offset(self) -> float: - """Return offset value, defaulting to 0 if not provided or invalid.""" - offset = self.cleaned_data.get("offset") - return offset if offset is not None else 0.0 - - def clean_users(self) -> set[int]: - """Parse user IDs from form data, handling both comma-separated and multiple params.""" - result = set() - # Handle multiple params (from select multiple) and comma-separated values - values = ( - self.data.getlist("users") - if hasattr(self.data, "getlist") - else [self.data.get("users", "")] - ) - for value in values: - for uid in str(value).split(","): - if uid.strip().isdigit(): - result.add(int(uid.strip())) - return result - - def clean_session(self) -> Session | None: - if session := self.cleaned_data.get("session"): - self._session_membership = ( - SessionMembership.objects.for_session(session) - .for_user(self.user) - .first() - ) - self._session = session - return self._session - - def get_selectable_users(self) -> list[CustomUser]: - """ - Get users that the current user can select for comparison. - - Returns: - List of CustomUser objects the user can compare - """ - return list( - CustomUser.objects.for_comparing_availability( - user=self.user, - session=self._session, - session_membership=self._session_membership, - ) - ) - - def get_selected_users( - self, selectable_users: list[CustomUser] - ) -> list[CustomUser]: - """ - Get the users that are currently selected from the selectable users. - - Args: - selectable_users: List of users that can be selected - - Returns: - List of selected CustomUser objects - """ - if not self.cleaned_data["users"]: - return [] - return [u for u in selectable_users if u.id in self.cleaned_data["users"]] - - def get_offset_hours(self) -> float: - """Return the validated offset hours value.""" - return self.cleaned_data.get("offset", 0) - - @login_required def compare_availability(request): """ diff --git a/home/views/sessions.py b/home/views/sessions.py index a2af024d..5f12c285 100644 --- a/home/views/sessions.py +++ b/home/views/sessions.py @@ -1,13 +1,20 @@ """Session-related views.""" +from datetime import date, timedelta from typing import Any +from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin from django.db.models import Prefetch, QuerySet +from django.http import HttpRequest, HttpResponse +from django.shortcuts import get_object_or_404, redirect, render from django.views.generic.detail import DetailView from django.views.generic.list import ListView +from github import GithubException +from home.forms import CollectStatsForm from home.models import Session, SessionMembership +from home.services.github_stats import Author, GitHubStatsCollector, TeamScope class SessionDetailView(DetailView): @@ -72,3 +79,139 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context["current_session_membership"] = current_session_membership return context + + +def _scope_term_for_project(project) -> str | None: + """Build a GitHub search scope qualifier for a project's GitHub URL.""" + github_repo = project.github_repo + if github_repo is None: + return None + owner, repo_name = github_repo + if project.monitor_all_organization_repos: + return f"org:{owner}" + return f"repo:{owner}/{repo_name}" + + +def _build_team_scopes(session: Session) -> list[TeamScope]: + """Build one ``TeamScope`` per team with a GitHub project and djangonauts. + + Teams whose project has no GitHub URL, or whose djangonauts have no + GitHub username configured, produce no queries and are skipped. + """ + djangonaut_members = Prefetch( + "session_memberships", + queryset=SessionMembership.objects.djangonauts().select_related( + "user__profile" + ), + to_attr="team_djangonauts", + ) + teams = session.teams.select_related("project").prefetch_related(djangonaut_members) + + scopes: list[TeamScope] = [] + for team in teams: + scope_term = _scope_term_for_project(team.project) + if scope_term is None: + continue + + members_by_login: dict[str, Author] = {} + for membership in team.team_djangonauts: + github_username = membership.user.profile.github_username + if not github_username or github_username in members_by_login: + continue + display_name = membership.user.get_full_name() or github_username + members_by_login[github_username] = Author( + github_username=github_username, name=display_name + ) + + if not members_by_login: + continue + + scopes.append( + TeamScope( + scope_term=scope_term, + members=tuple(members_by_login.values()), + label=str(team), + ) + ) + + return scopes + + +def collect_stats_view(request: HttpRequest, session_id: int) -> HttpResponse: + """ + Collect and display GitHub stats for Djangonauts in a session. + + Access is controlled by admin_site.admin_view() in SessionAdmin.get_urls(). + Additionally checks that the user is authorized for this specific session. + """ + session = get_object_or_404( + Session.objects.for_admin_site(request.user), + id=session_id, + ) + + scopes = _build_team_scopes(session) + if not scopes: + messages.error( + request, + "No teams with GitHub projects and configured Djangonaut GitHub " + "usernames were found for this session.", + ) + return redirect("admin:home_session_changelist") + + djangonaut_count = len( + {member.github_username for scope in scopes for member in scope.members} + ) + + if request.method == "POST": + form = CollectStatsForm(request.POST) + if form.is_valid(): + try: + report = GitHubStatsCollector().collect_all_stats( + scopes=scopes, + start_date=form.cleaned_data["start_date"], + end_date=form.cleaned_data["end_date"], + ) + except (GithubException, ValueError) as e: + messages.error(request, f"GitHub API error: {e}") + return redirect("admin:home_session_changelist") + + messages.success( + request, + f"Successfully collected stats for {djangonaut_count} Djangonauts. " + f"Found {report.count_open_prs()} open PRs, " + f"{report.count_merged_prs()} merged PRs, " + f"{report.count_closed_prs()} closed PRs, " + f"and {report.count_open_issues()} issues.", + ) + + return render( + request, + "admin/collect_stats_results.html", + { + "session": session, + "report": report, + "opts": Session._meta, + "has_view_permission": True, + }, + ) + else: + today = date.today() + form = CollectStatsForm( + initial={ + "start_date": today - timedelta(days=7), + "end_date": today, + } + ) + + return render( + request, + "admin/collect_stats_form.html", + { + "session": session, + "form": form, + "scopes": scopes, + "djangonaut_count": djangonaut_count, + "opts": Session._meta, + "has_view_permission": True, + }, + ) diff --git a/home/views/surveys.py b/home/views/surveys.py index 1716cb7c..dc4bd704 100644 --- a/home/views/surveys.py +++ b/home/views/surveys.py @@ -1,7 +1,5 @@ """Survey-related views.""" -from gettext import gettext - from django.contrib import messages from django.contrib.auth.mixins import LoginRequiredMixin, UserPassesTestMixin from django.db.models import Prefetch @@ -9,6 +7,7 @@ from django.shortcuts import redirect from django.urls import reverse, reverse_lazy from django.utils import timezone +from django.utils.translation import gettext from django.views.generic.detail import DetailView from django.views.generic.edit import FormMixin, ModelFormMixin diff --git a/home/views/testimonials.py b/home/views/testimonials.py index ef033684..0105ca26 100644 --- a/home/views/testimonials.py +++ b/home/views/testimonials.py @@ -1,5 +1,6 @@ """Testimonial-related views.""" +import random from typing import Any from django.contrib import messages @@ -33,9 +34,16 @@ def get_queryset(self) -> QuerySet[Testimonial]: def get_context_data(self, **kwargs: Any) -> dict[str, Any]: """Add hero testimonial and highlight info to context.""" context = super().get_context_data(**kwargs) - context["hero_testimonial"] = ( - Testimonial.objects.published().order_by("?").first() + published_ids = list( + Testimonial.objects.published().values_list("pk", flat=True) ) + if published_ids: + hero_pk = random.choice(published_ids) + context["hero_testimonial"] = Testimonial.objects.select_related( + "author", "session" + ).get(pk=hero_pk) + else: + context["hero_testimonial"] = None # Check if user can create testimonials (has session memberships with available sessions) context["can_create_testimonial"] = False @@ -104,20 +112,28 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: return context -class TestimonialUpdateView(LoginRequiredMixin, UserPassesTestMixin, UpdateView): - """Update an existing testimonial. Only the author can edit.""" +class AuthorTestimonialMixin(LoginRequiredMixin, UserPassesTestMixin): + """Mixin that restricts testimonial access to the author.""" model = Testimonial - form_class = TestimonialForm - template_name = "home/testimonials/form.html" - success_url = reverse_lazy("profile") slug_field = "slug" slug_url_kwarg = "slug" + def get_queryset(self) -> QuerySet[Testimonial]: + """Limit queryset to testimonials authored by the current user.""" + return super().get_queryset().filter(author=self.request.user) + def test_func(self) -> bool: """Check if user is the author of the testimonial.""" - testimonial = self.get_object() - return testimonial.author == self.request.user + return self.get_queryset().filter(slug=self.kwargs["slug"]).exists() + + +class TestimonialUpdateView(AuthorTestimonialMixin, UpdateView): + """Update an existing testimonial. Only the author can edit.""" + + form_class = TestimonialForm + template_name = "home/testimonials/form.html" + success_url = reverse_lazy("profile") def get_form_kwargs(self) -> dict[str, Any]: """Pass user to form.""" @@ -127,12 +143,11 @@ def get_form_kwargs(self) -> dict[str, Any]: def form_valid(self, form: TestimonialForm) -> HttpResponse: """Unpublish on edit and trigger notification.""" - # Capture old values before saving - old_testimonial = Testimonial.objects.get(pk=self.object.pk) + # Capture old values from the already-loaded object before saving old_values = { - "title": old_testimonial.title, - "text": old_testimonial.text, - "session_id": old_testimonial.session_id, + "title": self.object.title, + "text": self.object.text, + "session_id": self.object.session_id, } # Unpublish on edit - requires re-approval @@ -164,21 +179,13 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]: return context -class TestimonialDeleteView(LoginRequiredMixin, UserPassesTestMixin, DeleteView): +class TestimonialDeleteView(AuthorTestimonialMixin, DeleteView): """Delete a testimonial. Only the author can delete.""" - model = Testimonial template_name = "home/testimonials/confirm_delete.html" success_url = reverse_lazy("profile") - slug_field = "slug" - slug_url_kwarg = "slug" - def test_func(self) -> bool: - """Check if user is the author of the testimonial.""" - testimonial = self.get_object() - return testimonial.author == self.request.user - - def form_valid(self, form) -> HttpResponse: + def form_valid(self, form: TestimonialForm) -> HttpResponse: """Add success message on deletion.""" messages.success( self.request, diff --git a/indymeet/settings/base.py b/indymeet/settings/base.py index 204ce527..221fd05b 100644 --- a/indymeet/settings/base.py +++ b/indymeet/settings/base.py @@ -17,11 +17,12 @@ from django.forms.renderers import TemplatesSetting from dotenv import load_dotenv -load_dotenv() - PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(PROJECT_DIR) +# Load .env file explicitly +load_dotenv(os.path.join(BASE_DIR, ".env")) + # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/stable/howto/deployment/checklist/ @@ -134,6 +135,11 @@ } +# GitHub API Configuration +# Used for collecting Djangonaut stats +GITHUB_TOKEN = os.environ.get("GITHUB_TOKEN", "") + + # Password validation # https://docs.djangoproject.com/en/stable/ref/settings/#auth-password-validators @@ -252,6 +258,11 @@ }, "loggers": { "django.request": {"handlers": [], "level": "ERROR"}, + "home.services.github_stats": { + "handlers": ["console"], + "level": "INFO", + "propagate": False, + }, }, } diff --git a/pyproject.toml b/pyproject.toml index 2dd5f9cc..2a77333a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "dj-database-url", "gunicorn", "django-filter>=25.2", + "pygithub>=2.8.1", "django-tasks", "django-tasks-db", "django-import-export", diff --git a/uv.lock b/uv.lock index 56558561..2455a6f3 100644 --- a/uv.lock +++ b/uv.lock @@ -59,6 +59,76 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, + { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, + { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, + { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, + { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, + { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, + { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, + { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, + { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, + { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, + { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +] + [[package]] name = "cfgv" version = "3.4.0" @@ -222,6 +292,65 @@ toml = [ { name = "tomli", marker = "python_full_version <= '3.11'" }, ] +[[package]] +name = "cryptography" +version = "46.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/19/f748958276519adf6a0c1e79e7b8860b4830dda55ccdf29f2719b5fc499c/cryptography-46.0.4.tar.gz", hash = "sha256:bfd019f60f8abc2ed1b9be4ddc21cfef059c841d86d710bb69909a688cbb8f59", size = 749301, upload-time = "2026-01-28T00:24:37.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/99/157aae7949a5f30d51fcb1a9851e8ebd5c74bf99b5285d8bb4b8b9ee641e/cryptography-46.0.4-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:281526e865ed4166009e235afadf3a4c4cba6056f99336a99efba65336fd5485", size = 7173686, upload-time = "2026-01-28T00:23:07.515Z" }, + { url = "https://files.pythonhosted.org/packages/87/91/874b8910903159043b5c6a123b7e79c4559ddd1896e38967567942635778/cryptography-46.0.4-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5f14fba5bf6f4390d7ff8f086c566454bff0411f6d8aa7af79c88b6f9267aecc", size = 4275871, upload-time = "2026-01-28T00:23:09.439Z" }, + { url = "https://files.pythonhosted.org/packages/c0/35/690e809be77896111f5b195ede56e4b4ed0435b428c2f2b6d35046fbb5e8/cryptography-46.0.4-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:47bcd19517e6389132f76e2d5303ded6cf3f78903da2158a671be8de024f4cd0", size = 4423124, upload-time = "2026-01-28T00:23:11.529Z" }, + { url = "https://files.pythonhosted.org/packages/1a/5b/a26407d4f79d61ca4bebaa9213feafdd8806dc69d3d290ce24996d3cfe43/cryptography-46.0.4-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:01df4f50f314fbe7009f54046e908d1754f19d0c6d3070df1e6268c5a4af09fa", size = 4277090, upload-time = "2026-01-28T00:23:13.123Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d8/4bb7aec442a9049827aa34cee1aa83803e528fa55da9a9d45d01d1bb933e/cryptography-46.0.4-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5aa3e463596b0087b3da0dbe2b2487e9fc261d25da85754e30e3b40637d61f81", size = 4947652, upload-time = "2026-01-28T00:23:14.554Z" }, + { url = "https://files.pythonhosted.org/packages/2b/08/f83e2e0814248b844265802d081f2fac2f1cbe6cd258e72ba14ff006823a/cryptography-46.0.4-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0a9ad24359fee86f131836a9ac3bffc9329e956624a2d379b613f8f8abaf5255", size = 4455157, upload-time = "2026-01-28T00:23:16.443Z" }, + { url = "https://files.pythonhosted.org/packages/0a/05/19d849cf4096448779d2dcc9bb27d097457dac36f7273ffa875a93b5884c/cryptography-46.0.4-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:dc1272e25ef673efe72f2096e92ae39dea1a1a450dd44918b15351f72c5a168e", size = 3981078, upload-time = "2026-01-28T00:23:17.838Z" }, + { url = "https://files.pythonhosted.org/packages/e6/89/f7bac81d66ba7cde867a743ea5b37537b32b5c633c473002b26a226f703f/cryptography-46.0.4-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:de0f5f4ec8711ebc555f54735d4c673fc34b65c44283895f1a08c2b49d2fd99c", size = 4276213, upload-time = "2026-01-28T00:23:19.257Z" }, + { url = "https://files.pythonhosted.org/packages/da/9f/7133e41f24edd827020ad21b068736e792bc68eecf66d93c924ad4719fb3/cryptography-46.0.4-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:eeeb2e33d8dbcccc34d64651f00a98cb41b2dc69cef866771a5717e6734dfa32", size = 4912190, upload-time = "2026-01-28T00:23:21.244Z" }, + { url = "https://files.pythonhosted.org/packages/a6/f7/6d43cbaddf6f65b24816e4af187d211f0bc536a29961f69faedc48501d8e/cryptography-46.0.4-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:3d425eacbc9aceafd2cb429e42f4e5d5633c6f873f5e567077043ef1b9bbf616", size = 4454641, upload-time = "2026-01-28T00:23:22.866Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4f/ebd0473ad656a0ac912a16bd07db0f5d85184924e14fc88feecae2492834/cryptography-46.0.4-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91627ebf691d1ea3976a031b61fb7bac1ccd745afa03602275dda443e11c8de0", size = 4405159, upload-time = "2026-01-28T00:23:25.278Z" }, + { url = "https://files.pythonhosted.org/packages/d1/f7/7923886f32dc47e27adeff8246e976d77258fd2aa3efdd1754e4e323bf49/cryptography-46.0.4-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2d08bc22efd73e8854b0b7caff402d735b354862f1145d7be3b9c0f740fef6a0", size = 4666059, upload-time = "2026-01-28T00:23:26.766Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a7/0fca0fd3591dffc297278a61813d7f661a14243dd60f499a7a5b48acb52a/cryptography-46.0.4-cp311-abi3-win32.whl", hash = "sha256:82a62483daf20b8134f6e92898da70d04d0ef9a75829d732ea1018678185f4f5", size = 3026378, upload-time = "2026-01-28T00:23:28.317Z" }, + { url = "https://files.pythonhosted.org/packages/2d/12/652c84b6f9873f0909374864a57b003686c642ea48c84d6c7e2c515e6da5/cryptography-46.0.4-cp311-abi3-win_amd64.whl", hash = "sha256:6225d3ebe26a55dbc8ead5ad1265c0403552a63336499564675b29eb3184c09b", size = 3478614, upload-time = "2026-01-28T00:23:30.275Z" }, + { url = "https://files.pythonhosted.org/packages/b9/27/542b029f293a5cce59349d799d4d8484b3b1654a7b9a0585c266e974a488/cryptography-46.0.4-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:485e2b65d25ec0d901bca7bcae0f53b00133bf3173916d8e421f6fddde103908", size = 7116417, upload-time = "2026-01-28T00:23:31.958Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f5/559c25b77f40b6bf828eabaf988efb8b0e17b573545edb503368ca0a2a03/cryptography-46.0.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:078e5f06bd2fa5aea5a324f2a09f914b1484f1d0c2a4d6a8a28c74e72f65f2da", size = 4264508, upload-time = "2026-01-28T00:23:34.264Z" }, + { url = "https://files.pythonhosted.org/packages/49/a1/551fa162d33074b660dc35c9bc3616fefa21a0e8c1edd27b92559902e408/cryptography-46.0.4-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:dce1e4f068f03008da7fa51cc7abc6ddc5e5de3e3d1550334eaf8393982a5829", size = 4409080, upload-time = "2026-01-28T00:23:35.793Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6a/4d8d129a755f5d6df1bbee69ea2f35ebfa954fa1847690d1db2e8bca46a5/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:2067461c80271f422ee7bdbe79b9b4be54a5162e90345f86a23445a0cf3fd8a2", size = 4270039, upload-time = "2026-01-28T00:23:37.263Z" }, + { url = "https://files.pythonhosted.org/packages/4c/f5/ed3fcddd0a5e39321e595e144615399e47e7c153a1fb8c4862aec3151ff9/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:c92010b58a51196a5f41c3795190203ac52edfd5dc3ff99149b4659eba9d2085", size = 4926748, upload-time = "2026-01-28T00:23:38.884Z" }, + { url = "https://files.pythonhosted.org/packages/43/ae/9f03d5f0c0c00e85ecb34f06d3b79599f20630e4db91b8a6e56e8f83d410/cryptography-46.0.4-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:829c2b12bbc5428ab02d6b7f7e9bbfd53e33efd6672d21341f2177470171ad8b", size = 4442307, upload-time = "2026-01-28T00:23:40.56Z" }, + { url = "https://files.pythonhosted.org/packages/8b/22/e0f9f2dae8040695103369cf2283ef9ac8abe4d51f68710bec2afd232609/cryptography-46.0.4-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:62217ba44bf81b30abaeda1488686a04a702a261e26f87db51ff61d9d3510abd", size = 3959253, upload-time = "2026-01-28T00:23:42.827Z" }, + { url = "https://files.pythonhosted.org/packages/01/5b/6a43fcccc51dae4d101ac7d378a8724d1ba3de628a24e11bf2f4f43cba4d/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:9c2da296c8d3415b93e6053f5a728649a87a48ce084a9aaf51d6e46c87c7f2d2", size = 4269372, upload-time = "2026-01-28T00:23:44.655Z" }, + { url = "https://files.pythonhosted.org/packages/17/b7/0f6b8c1dd0779df2b526e78978ff00462355e31c0a6f6cff8a3e99889c90/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9b34d8ba84454641a6bf4d6762d15847ecbd85c1316c0a7984e6e4e9f748ec2e", size = 4891908, upload-time = "2026-01-28T00:23:46.48Z" }, + { url = "https://files.pythonhosted.org/packages/83/17/259409b8349aa10535358807a472c6a695cf84f106022268d31cea2b6c97/cryptography-46.0.4-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:df4a817fa7138dd0c96c8c8c20f04b8aaa1fac3bbf610913dcad8ea82e1bfd3f", size = 4441254, upload-time = "2026-01-28T00:23:48.403Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fe/e4a1b0c989b00cee5ffa0764401767e2d1cf59f45530963b894129fd5dce/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:b1de0ebf7587f28f9190b9cb526e901bf448c9e6a99655d2b07fff60e8212a82", size = 4396520, upload-time = "2026-01-28T00:23:50.26Z" }, + { url = "https://files.pythonhosted.org/packages/b3/81/ba8fd9657d27076eb40d6a2f941b23429a3c3d2f56f5a921d6b936a27bc9/cryptography-46.0.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9b4d17bc7bd7cdd98e3af40b441feaea4c68225e2eb2341026c84511ad246c0c", size = 4651479, upload-time = "2026-01-28T00:23:51.674Z" }, + { url = "https://files.pythonhosted.org/packages/00/03/0de4ed43c71c31e4fe954edd50b9d28d658fef56555eba7641696370a8e2/cryptography-46.0.4-cp314-cp314t-win32.whl", hash = "sha256:c411f16275b0dea722d76544a61d6421e2cc829ad76eec79280dbdc9ddf50061", size = 3001986, upload-time = "2026-01-28T00:23:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/5c/70/81830b59df7682917d7a10f833c4dab2a5574cd664e86d18139f2b421329/cryptography-46.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:728fedc529efc1439eb6107b677f7f7558adab4553ef8669f0d02d42d7b959a7", size = 3468288, upload-time = "2026-01-28T00:23:55.09Z" }, + { url = "https://files.pythonhosted.org/packages/56/f7/f648fdbb61d0d45902d3f374217451385edc7e7768d1b03ff1d0e5ffc17b/cryptography-46.0.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a9556ba711f7c23f77b151d5798f3ac44a13455cc68db7697a1096e6d0563cab", size = 7169583, upload-time = "2026-01-28T00:23:56.558Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cc/8f3224cbb2a928de7298d6ed4790f5ebc48114e02bdc9559196bfb12435d/cryptography-46.0.4-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bf75b0259e87fa70bddc0b8b4078b76e7fd512fd9afae6c1193bcf440a4dbef", size = 4275419, upload-time = "2026-01-28T00:23:58.364Z" }, + { url = "https://files.pythonhosted.org/packages/17/43/4a18faa7a872d00e4264855134ba82d23546c850a70ff209e04ee200e76f/cryptography-46.0.4-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3c268a3490df22270955966ba236d6bc4a8f9b6e4ffddb78aac535f1a5ea471d", size = 4419058, upload-time = "2026-01-28T00:23:59.867Z" }, + { url = "https://files.pythonhosted.org/packages/ee/64/6651969409821d791ba12346a124f55e1b76f66a819254ae840a965d4b9c/cryptography-46.0.4-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:812815182f6a0c1d49a37893a303b44eaac827d7f0d582cecfc81b6427f22973", size = 4278151, upload-time = "2026-01-28T00:24:01.731Z" }, + { url = "https://files.pythonhosted.org/packages/20/0b/a7fce65ee08c3c02f7a8310cc090a732344066b990ac63a9dfd0a655d321/cryptography-46.0.4-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:a90e43e3ef65e6dcf969dfe3bb40cbf5aef0d523dff95bfa24256be172a845f4", size = 4939441, upload-time = "2026-01-28T00:24:03.175Z" }, + { url = "https://files.pythonhosted.org/packages/db/a7/20c5701e2cd3e1dfd7a19d2290c522a5f435dd30957d431dcb531d0f1413/cryptography-46.0.4-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a05177ff6296644ef2876fce50518dffb5bcdf903c85250974fc8bc85d54c0af", size = 4451617, upload-time = "2026-01-28T00:24:05.403Z" }, + { url = "https://files.pythonhosted.org/packages/00/dc/3e16030ea9aa47b63af6524c354933b4fb0e352257c792c4deeb0edae367/cryptography-46.0.4-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:daa392191f626d50f1b136c9b4cf08af69ca8279d110ea24f5c2700054d2e263", size = 3977774, upload-time = "2026-01-28T00:24:06.851Z" }, + { url = "https://files.pythonhosted.org/packages/42/c8/ad93f14118252717b465880368721c963975ac4b941b7ef88f3c56bf2897/cryptography-46.0.4-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e07ea39c5b048e085f15923511d8121e4a9dc45cee4e3b970ca4f0d338f23095", size = 4277008, upload-time = "2026-01-28T00:24:08.926Z" }, + { url = "https://files.pythonhosted.org/packages/00/cf/89c99698151c00a4631fbfcfcf459d308213ac29e321b0ff44ceeeac82f1/cryptography-46.0.4-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d5a45ddc256f492ce42a4e35879c5e5528c09cd9ad12420828c972951d8e016b", size = 4903339, upload-time = "2026-01-28T00:24:12.009Z" }, + { url = "https://files.pythonhosted.org/packages/03/c3/c90a2cb358de4ac9309b26acf49b2a100957e1ff5cc1e98e6c4996576710/cryptography-46.0.4-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:6bb5157bf6a350e5b28aee23beb2d84ae6f5be390b2f8ee7ea179cda077e1019", size = 4451216, upload-time = "2026-01-28T00:24:13.975Z" }, + { url = "https://files.pythonhosted.org/packages/96/2c/8d7f4171388a10208671e181ca43cdc0e596d8259ebacbbcfbd16de593da/cryptography-46.0.4-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:dd5aba870a2c40f87a3af043e0dee7d9eb02d4aff88a797b48f2b43eff8c3ab4", size = 4404299, upload-time = "2026-01-28T00:24:16.169Z" }, + { url = "https://files.pythonhosted.org/packages/e9/23/cbb2036e450980f65c6e0a173b73a56ff3bccd8998965dea5cc9ddd424a5/cryptography-46.0.4-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:93d8291da8d71024379ab2cb0b5c57915300155ad42e07f76bea6ad838d7e59b", size = 4664837, upload-time = "2026-01-28T00:24:17.629Z" }, + { url = "https://files.pythonhosted.org/packages/0a/21/f7433d18fe6d5845329cbdc597e30caf983229c7a245bcf54afecc555938/cryptography-46.0.4-cp38-abi3-win32.whl", hash = "sha256:0563655cb3c6d05fb2afe693340bc050c30f9f34e15763361cf08e94749401fc", size = 3009779, upload-time = "2026-01-28T00:24:20.198Z" }, + { url = "https://files.pythonhosted.org/packages/3a/6a/bd2e7caa2facffedf172a45c1a02e551e6d7d4828658c9a245516a598d94/cryptography-46.0.4-cp38-abi3-win_amd64.whl", hash = "sha256:fa0900b9ef9c49728887d1576fd8d9e7e3ea872fa9b25ef9b64888adc434e976", size = 3466633, upload-time = "2026-01-28T00:24:21.851Z" }, + { url = "https://files.pythonhosted.org/packages/59/e0/f9c6c53e1f2a1c2507f00f2faba00f01d2f334b35b0fbfe5286715da2184/cryptography-46.0.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:766330cce7416c92b5e90c3bb71b1b79521760cdcfc3a6a1a182d4c9fab23d2b", size = 3476316, upload-time = "2026-01-28T00:24:24.144Z" }, + { url = "https://files.pythonhosted.org/packages/27/7a/f8d2d13227a9a1a9fe9c7442b057efecffa41f1e3c51d8622f26b9edbe8f/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:c236a44acfb610e70f6b3e1c3ca20ff24459659231ef2f8c48e879e2d32b73da", size = 4216693, upload-time = "2026-01-28T00:24:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/c5/de/3787054e8f7972658370198753835d9d680f6cd4a39df9f877b57f0dd69c/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:8a15fb869670efa8f83cbffbc8753c1abf236883225aed74cd179b720ac9ec80", size = 4382765, upload-time = "2026-01-28T00:24:27.577Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5f/60e0afb019973ba6a0b322e86b3d61edf487a4f5597618a430a2a15f2d22/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:fdc3daab53b212472f1524d070735b2f0c214239df131903bae1d598016fa822", size = 4216066, upload-time = "2026-01-28T00:24:29.056Z" }, + { url = "https://files.pythonhosted.org/packages/81/8e/bf4a0de294f147fee66f879d9bae6f8e8d61515558e3d12785dd90eca0be/cryptography-46.0.4-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:44cc0675b27cadb71bdbb96099cca1fa051cd11d2ade09e5cd3a2edb929ed947", size = 4382025, upload-time = "2026-01-28T00:24:30.681Z" }, + { url = "https://files.pythonhosted.org/packages/79/f4/9ceb90cfd6a3847069b0b0b353fd3075dc69b49defc70182d8af0c4ca390/cryptography-46.0.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be8c01a7d5a55f9a47d1888162b76c8f49d62b234d88f0ff91a9fbebe32ffbc3", size = 3406043, upload-time = "2026-01-28T00:24:32.236Z" }, +] + [[package]] name = "defusedxml" version = "0.7.1" @@ -532,6 +661,7 @@ dependencies = [ { name = "gunicorn" }, { name = "psycopg", extra = ["binary"] }, { name = "puput" }, + { name = "pygithub" }, { name = "python-dotenv" }, { name = "sentry-sdk", extra = ["django"] }, { name = "six" }, @@ -584,6 +714,7 @@ requires-dist = [ { name = "pre-commit", marker = "extra == 'dev'" }, { name = "psycopg", extras = ["binary"] }, { name = "puput" }, + { name = "pygithub", specifier = ">=2.8.1" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, { name = "pytest-django", marker = "extra == 'test'" }, @@ -1094,6 +1225,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/82/ef/a43721e8996cd4883a3aa6f76e2694523bf6dfeb7a9296e914067357c2d7/puput-2.2.0-py3-none-any.whl", hash = "sha256:eb5c6e2ecbd725450fc5498dc6ab720376f4435907fad8fd8e162831698901f2", size = 483771, upload-time = "2025-04-07T13:16:02.594Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pyee" version = "13.0.0" @@ -1106,6 +1246,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730, upload-time = "2025-03-17T18:53:14.532Z" }, ] +[[package]] +name = "pygithub" +version = "2.8.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "pynacl" }, + { name = "requests" }, + { name = "typing-extensions" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c1/74/e560bdeffea72ecb26cff27f0fad548bbff5ecc51d6a155311ea7f9e4c4c/pygithub-2.8.1.tar.gz", hash = "sha256:341b7c78521cb07324ff670afd1baa2bf5c286f8d9fd302c1798ba594a5400c9", size = 2246994, upload-time = "2025-09-02T17:41:54.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/ba/7049ce39f653f6140aac4beb53a5aaf08b4407b6a3019aae394c1c5244ff/pygithub-2.8.1-py3-none-any.whl", hash = "sha256:23a0a5bca93baef082e03411bf0ce27204c32be8bfa7abc92fe4a3e132936df0", size = 432709, upload-time = "2025-09-02T17:41:52.947Z" }, +] + [[package]] name = "pygments" version = "2.19.2" @@ -1115,6 +1271,55 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, ] +[[package]] +name = "pyjwt" +version = "2.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5c/5a/b46fa56bf322901eee5b0454a34343cdbdae202cd421775a8ee4e42fd519/pyjwt-2.11.0.tar.gz", hash = "sha256:35f95c1f0fbe5d5ba6e43f00271c275f7a1a4db1dab27bf708073b75318ea623", size = 98019, upload-time = "2026-01-30T19:59:55.694Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6f/01/c26ce75ba460d5cd503da9e13b21a33804d38c2165dec7b716d06b13010c/pyjwt-2.11.0-py3-none-any.whl", hash = "sha256:94a6bde30eb5c8e04fee991062b534071fd1439ef58d2adc9ccb823e7bcd0469", size = 28224, upload-time = "2026-01-30T19:59:54.539Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pynacl" +version = "1.6.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/9a/4019b524b03a13438637b11538c82781a5eda427394380381af8f04f467a/pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c", size = 3511692, upload-time = "2026-01-01T17:48:10.851Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4b/79/0e3c34dc3c4671f67d251c07aa8eb100916f250ee470df230b0ab89551b4/pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594", size = 390064, upload-time = "2026-01-01T17:31:57.264Z" }, + { url = "https://files.pythonhosted.org/packages/eb/1c/23a26e931736e13b16483795c8a6b2f641bf6a3d5238c22b070a5112722c/pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0", size = 809370, upload-time = "2026-01-01T17:31:59.198Z" }, + { url = "https://files.pythonhosted.org/packages/87/74/8d4b718f8a22aea9e8dcc8b95deb76d4aae380e2f5b570cc70b5fd0a852d/pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9", size = 1408304, upload-time = "2026-01-01T17:32:01.162Z" }, + { url = "https://files.pythonhosted.org/packages/fd/73/be4fdd3a6a87fe8a4553380c2b47fbd1f7f58292eb820902f5c8ac7de7b0/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574", size = 844871, upload-time = "2026-01-01T17:32:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/55/ad/6efc57ab75ee4422e96b5f2697d51bbcf6cdcc091e66310df91fbdc144a8/pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634", size = 1446356, upload-time = "2026-01-01T17:32:04.452Z" }, + { url = "https://files.pythonhosted.org/packages/78/b7/928ee9c4779caa0a915844311ab9fb5f99585621c5d6e4574538a17dca07/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88", size = 826814, upload-time = "2026-01-01T17:32:06.078Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a9/1bdba746a2be20f8809fee75c10e3159d75864ef69c6b0dd168fc60e485d/pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14", size = 1411742, upload-time = "2026-01-01T17:32:07.651Z" }, + { url = "https://files.pythonhosted.org/packages/f3/2f/5e7ea8d85f9f3ea5b6b87db1d8388daa3587eed181bdeb0306816fdbbe79/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444", size = 801714, upload-time = "2026-01-01T17:32:09.558Z" }, + { url = "https://files.pythonhosted.org/packages/06/ea/43fe2f7eab5f200e40fb10d305bf6f87ea31b3bbc83443eac37cd34a9e1e/pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b", size = 1372257, upload-time = "2026-01-01T17:32:11.026Z" }, + { url = "https://files.pythonhosted.org/packages/4d/54/c9ea116412788629b1347e415f72195c25eb2f3809b2d3e7b25f5c79f13a/pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145", size = 231319, upload-time = "2026-01-01T17:32:12.46Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/64e9d76646abac2dccf904fccba352a86e7d172647557f35b9fe2a5ee4a1/pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590", size = 244044, upload-time = "2026-01-01T17:32:13.781Z" }, + { url = "https://files.pythonhosted.org/packages/33/33/7873dc161c6a06f43cda13dec67b6fe152cb2f982581151956fa5e5cdb47/pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2", size = 188740, upload-time = "2026-01-01T17:32:15.083Z" }, + { url = "https://files.pythonhosted.org/packages/be/7b/4845bbf88e94586ec47a432da4e9107e3fc3ce37eb412b1398630a37f7dd/pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465", size = 388458, upload-time = "2026-01-01T17:32:16.829Z" }, + { url = "https://files.pythonhosted.org/packages/1e/b4/e927e0653ba63b02a4ca5b4d852a8d1d678afbf69b3dbf9c4d0785ac905c/pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0", size = 800020, upload-time = "2026-01-01T17:32:18.34Z" }, + { url = "https://files.pythonhosted.org/packages/7f/81/d60984052df5c97b1d24365bc1e30024379b42c4edcd79d2436b1b9806f2/pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4", size = 1399174, upload-time = "2026-01-01T17:32:20.239Z" }, + { url = "https://files.pythonhosted.org/packages/68/f7/322f2f9915c4ef27d140101dd0ed26b479f7e6f5f183590fd32dfc48c4d3/pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87", size = 835085, upload-time = "2026-01-01T17:32:22.24Z" }, + { url = "https://files.pythonhosted.org/packages/3e/d0/f301f83ac8dbe53442c5a43f6a39016f94f754d7a9815a875b65e218a307/pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c", size = 1437614, upload-time = "2026-01-01T17:32:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/c4/58/fc6e649762b029315325ace1a8c6be66125e42f67416d3dbd47b69563d61/pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130", size = 818251, upload-time = "2026-01-01T17:32:25.69Z" }, + { url = "https://files.pythonhosted.org/packages/c9/a8/b917096b1accc9acd878819a49d3d84875731a41eb665f6ebc826b1af99e/pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6", size = 1402859, upload-time = "2026-01-01T17:32:27.215Z" }, + { url = "https://files.pythonhosted.org/packages/85/42/fe60b5f4473e12c72f977548e4028156f4d340b884c635ec6b063fe7e9a5/pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e", size = 791926, upload-time = "2026-01-01T17:32:29.314Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f9/e40e318c604259301cc091a2a63f237d9e7b424c4851cafaea4ea7c4834e/pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577", size = 1363101, upload-time = "2026-01-01T17:32:31.263Z" }, + { url = "https://files.pythonhosted.org/packages/48/47/e761c254f410c023a469284a9bc210933e18588ca87706ae93002c05114c/pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa", size = 227421, upload-time = "2026-01-01T17:32:33.076Z" }, + { url = "https://files.pythonhosted.org/packages/41/ad/334600e8cacc7d86587fe5f565480fde569dfb487389c8e1be56ac21d8ac/pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0", size = 239754, upload-time = "2026-01-01T17:32:34.557Z" }, + { url = "https://files.pythonhosted.org/packages/29/7d/5945b5af29534641820d3bd7b00962abbbdfee84ec7e19f0d5b3175f9a31/pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c", size = 184801, upload-time = "2026-01-01T17:32:36.309Z" }, +] + [[package]] name = "pytailwindcss" version = "0.3.0"