diff --git a/.gitignore b/.gitignore index 12e3c5a9..d2cbe56f 100644 --- a/.gitignore +++ b/.gitignore @@ -169,6 +169,9 @@ state-snapshots.txt request.json +# Tailwind CSS build output +daiv/accounts/static/accounts/css/styles.css + # SWE-bench daiv-traces/ predictions.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 565d0519..226949c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added web-based authentication via django-allauth with GitHub and GitLab social login, passwordless login-by-code for existing users, and a styled dark-themed login page. Includes a post-login dashboard with API key management (create, list, revoke). +- Added activity counters to the dashboard showing jobs processed, success rate, issues resolved, MRs assisted, and active API keys, with a temporal filter (7d/30d/90d/all time). - Added `setup_langsmith_dashboard` management command to create a pre-configured LangSmith custom dashboard with 27 monitoring charts covering trace volume, latency, cost, tool usage, model comparison, and pipeline health. Supports `--project` and `--recreate` options. - Added `model` and `thinking_level` metadata to all LangSmith traces, enabling per-model dashboards and A/B comparison when switching between models. - Added async Jobs API (`POST /api/jobs`, `GET /api/jobs/{id}`) for programmatic agent execution with configurable per-user rate limiting (`JOBS_THROTTLE_RATE`). Enables scheduled CI pipelines, Slack bots, and scripted workflows to trigger DAIV agents without blocking. diff --git a/Makefile b/Makefile index 59734b1c..76963fd2 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ # Makefile -.PHONY: help setup test test-ci lint lint-check lint-format lint-fix lint-typing evals +.PHONY: help setup test test-ci lint lint-check lint-format lint-fix lint-typing evals tailwind-build tailwind-watch help: @echo "Available commands:" @@ -103,5 +103,11 @@ swerebench-clean: docs-serve: uv run --only-group=docs mkdocs serve -o -a localhost:4000 -w docs/ +tailwind-build: + docker compose exec app tailwindcss -i daiv/accounts/static_src/css/input.css -o daiv/accounts/static/accounts/css/styles.css --minify + +tailwind-watch: + docker compose exec app tailwindcss -i daiv/accounts/static_src/css/input.css -o daiv/accounts/static/accounts/css/styles.css --watch + langsmith-fetch: uv run langsmith-fetch traces --project-uuid 00d1a04e-0087-4813-9a18-5995cd5bee5c --limit 1 ./daiv-traces diff --git a/daiv/accounts/adapter.py b/daiv/accounts/adapter.py new file mode 100644 index 00000000..c085f90e --- /dev/null +++ b/daiv/accounts/adapter.py @@ -0,0 +1,14 @@ +from allauth.account.adapter import DefaultAccountAdapter +from allauth.socialaccount.adapter import DefaultSocialAccountAdapter + + +class AccountAdapter(DefaultAccountAdapter): + def is_open_for_signup(self, request): + """Disable standard email/password signup. Users are created via social providers only.""" + return False + + +class SocialAccountAdapter(DefaultSocialAccountAdapter): + def is_open_for_signup(self, request, sociallogin): + """Allow new user creation via social providers (GitHub, GitLab).""" + return True diff --git a/daiv/accounts/allauth_urls.py b/daiv/accounts/allauth_urls.py new file mode 100644 index 00000000..80a75c78 --- /dev/null +++ b/daiv/accounts/allauth_urls.py @@ -0,0 +1,32 @@ +from django.urls import path +from django.views.generic import RedirectView + +from allauth.account import views as account_views +from allauth.socialaccount import views as socialaccount_views +from allauth.urls import build_provider_urlpatterns + +from accounts.socialaccount import oauth2_callback, oauth2_login + +urlpatterns = [ + # Custom GitLab OAuth adapter must come before auto-discovered provider + # URLs so it takes precedence. This lets us use a Docker-internal URL for + # server-side token exchange while keeping the browser-facing authorize URL + # unchanged. + path("gitlab/login/", oauth2_login, name="gitlab_login"), + path("gitlab/login/callback/", oauth2_callback, name="gitlab_callback"), + # Account views (login, logout, login-by-code only — no signup, password, + # or email management routes). + # Stub so allauth's login view can reverse("account_signup") without error; + # actual signup is disabled — visitors land back on the login page. + path("signup/", RedirectView.as_view(pattern_name="account_login", permanent=False), name="account_signup"), + path("login/", account_views.login, name="account_login"), + path("logout/", account_views.logout, name="account_logout"), + path("login/code/", account_views.request_login_code, name="account_request_login_code"), + path("login/code/confirm/", account_views.confirm_login_code, name="account_confirm_login_code"), + # Social account views required by the OAuth flow. + path("3rdparty/login/cancelled/", socialaccount_views.login_cancelled, name="socialaccount_login_cancelled"), + path("3rdparty/login/error/", socialaccount_views.login_error, name="socialaccount_login_error"), + path("3rdparty/signup/", socialaccount_views.signup, name="socialaccount_signup"), + # Auto-discovered OAuth provider URLs (GitHub, GitLab, etc.). + *build_provider_urlpatterns(), +] diff --git a/daiv/accounts/api_keys_urls.py b/daiv/accounts/api_keys_urls.py new file mode 100644 index 00000000..f67a7439 --- /dev/null +++ b/daiv/accounts/api_keys_urls.py @@ -0,0 +1,9 @@ +from django.urls import path + +from accounts.views import APIKeyCreateView, APIKeyListView, APIKeyRevokeView + +urlpatterns = [ + path("", APIKeyListView.as_view(), name="api_keys"), + path("create/", APIKeyCreateView.as_view(), name="api_key_create"), + path("/revoke/", APIKeyRevokeView.as_view(), name="api_key_revoke"), +] diff --git a/daiv/accounts/dashboard_urls.py b/daiv/accounts/dashboard_urls.py new file mode 100644 index 00000000..9eac277a --- /dev/null +++ b/daiv/accounts/dashboard_urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +from accounts.views import DashboardView + +urlpatterns = [path("", DashboardView.as_view(), name="dashboard")] diff --git a/daiv/accounts/forms.py b/daiv/accounts/forms.py new file mode 100644 index 00000000..daf259e3 --- /dev/null +++ b/daiv/accounts/forms.py @@ -0,0 +1,9 @@ +from django import forms + +from accounts.models import APIKey + + +class APIKeyCreateForm(forms.ModelForm): + class Meta: + model = APIKey + fields = ["name"] diff --git a/daiv/accounts/management/commands/setup_default_site.py b/daiv/accounts/management/commands/setup_default_site.py new file mode 100644 index 00000000..4c3e98a3 --- /dev/null +++ b/daiv/accounts/management/commands/setup_default_site.py @@ -0,0 +1,20 @@ +from django.conf import settings +from django.contrib.sites.models import Site +from django.core.management.base import BaseCommand, CommandError + +from core.conf import settings as core_settings + + +class Command(BaseCommand): + help = "Update the default Site domain from DAIV_EXTERNAL_URL." + + def handle(self, *args, **options): + domain = core_settings.EXTERNAL_URL.host + if not domain: + raise CommandError("DAIV_EXTERNAL_URL has no valid host. Check your DAIV_EXTERNAL_URL setting.") + + rows = Site.objects.filter(pk=settings.SITE_ID).update(domain=domain, name="DAIV") + if rows == 0: + raise CommandError(f"Site with pk={settings.SITE_ID} does not exist. Run 'migrate' first.") + + self.stdout.write(self.style.SUCCESS(f"Default site updated: domain={domain}, name=DAIV")) diff --git a/daiv/accounts/socialaccount.py b/daiv/accounts/socialaccount.py new file mode 100644 index 00000000..c8ec9843 --- /dev/null +++ b/daiv/accounts/socialaccount.py @@ -0,0 +1,35 @@ +from allauth.core import context +from allauth.socialaccount.adapter import get_adapter +from allauth.socialaccount.providers.gitlab.views import GitLabOAuth2Adapter +from allauth.socialaccount.providers.oauth2.views import OAuth2CallbackView, OAuth2LoginView + + +class GitLabServerAwareAdapter(GitLabOAuth2Adapter): + """ + GitLab adapter that supports a separate ``gitlab_server_url`` app setting + for server-side HTTP calls (token exchange, profile fetch). This is needed + in Docker/compose environments where the browser-facing URL differs from + the URL reachable inside the container network. + + When ``gitlab_server_url`` is empty or absent the adapter falls back to the + standard ``gitlab_url``. + """ + + def _build_server_url(self, path): + app = get_adapter().get_app(context.request, provider=self.provider_id) + server_url = app.settings.get("gitlab_server_url") + if server_url: + return f"{server_url}{path}" + return self._build_url(path) + + @property + def access_token_url(self): + return self._build_server_url("/oauth/token") + + @property + def profile_url(self): + return self._build_server_url(f"/api/{self.provider_api_version}/user") + + +oauth2_login = OAuth2LoginView.adapter_view(GitLabServerAwareAdapter) +oauth2_callback = OAuth2CallbackView.adapter_view(GitLabServerAwareAdapter) diff --git a/daiv/accounts/static/accounts/img/logo.png b/daiv/accounts/static/accounts/img/logo.png new file mode 100644 index 00000000..44c4121c Binary files /dev/null and b/daiv/accounts/static/accounts/img/logo.png differ diff --git a/daiv/accounts/static_src/css/input.css b/daiv/accounts/static_src/css/input.css new file mode 100644 index 00000000..a2b17d29 --- /dev/null +++ b/daiv/accounts/static_src/css/input.css @@ -0,0 +1,20 @@ +@import "tailwindcss"; + +@theme { + --font-sans: "Outfit", ui-sans-serif, system-ui, sans-serif; +} + +@keyframes fade-up { + from { + opacity: 0; + transform: translateY(10px); + } +} + +a, button, [role="button"] { + cursor: pointer; +} + +.animate-fade-up { + animation: fade-up 0.5s ease-out both; +} diff --git a/daiv/accounts/templates/account/confirm_login_code.html b/daiv/accounts/templates/account/confirm_login_code.html new file mode 100644 index 00000000..75704768 --- /dev/null +++ b/daiv/accounts/templates/account/confirm_login_code.html @@ -0,0 +1,63 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Enter code — DAIV{% endblock %} + +{% block content %} +
+
+
+ +
+ +
+ DAIV +
+ + +
+
+ + + +
+

Check your email

+

+ We sent a login code to your inbox.
Enter it below to continue. +

+
+ +
+ {% csrf_token %} +
+ {% for field in form %} +
+ {% if field.errors %} +

{{ field.errors.0 }}

+ {% endif %} + +
+ {% endfor %} + +
+
+ +

+ Back to sign in +

+
+
+{% endblock %} diff --git a/daiv/accounts/templates/account/login.html b/daiv/accounts/templates/account/login.html new file mode 100644 index 00000000..ca884410 --- /dev/null +++ b/daiv/accounts/templates/account/login.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} +{% load static socialaccount %} + +{% block title %}Sign in — DAIV{% endblock %} + +{% block content %} +
+ +
+
+ + +
+
+ + +
+
+
+
+
+
+ + +
+ + +
+ DAIV +

Sign in to your account

+
+ + + {% get_providers as socialaccount_providers %} + {% if socialaccount_providers %} +
+ {% for provider in socialaccount_providers %} + {% if provider.id == "github" %} + + + + + Continue with GitHub + + {% elif provider.id == "gitlab" %} + + + + + Continue with GitLab + + {% endif %} + {% endfor %} +
+ + +
+
+
+
+
+ or +
+
+ {% endif %} + + +
+ {% csrf_token %} +
+
+ +
+ +
+

+ We'll send a one-time code to your inbox +

+
+
+
+{% endblock %} diff --git a/daiv/accounts/templates/account/logout.html b/daiv/accounts/templates/account/logout.html new file mode 100644 index 00000000..90f9d1f0 --- /dev/null +++ b/daiv/accounts/templates/account/logout.html @@ -0,0 +1,25 @@ +{% extends "base.html" %} + +{% block title %}Sign out — DAIV{% endblock %} + +{% block content %} +
+
+
+

Sign out

+

Are you sure you want to sign out?

+
+
+ {% csrf_token %} + + Cancel + + +
+
+
+{% endblock %} diff --git a/daiv/accounts/templates/account/request_login_code.html b/daiv/accounts/templates/account/request_login_code.html new file mode 100644 index 00000000..6ac918f6 --- /dev/null +++ b/daiv/accounts/templates/account/request_login_code.html @@ -0,0 +1,49 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Sign in with email — DAIV{% endblock %} + +{% block content %} +
+
+
+ +
+ +
+ DAIV +

Enter your email to sign in

+
+ +
+ {% csrf_token %} +
+ {% for field in form %} +
+ {% if field.errors %} +

{{ field.errors.0 }}

+ {% endif %} + +
+ {% endfor %} + +
+
+ +

+ Back to sign in +

+
+
+{% endblock %} diff --git a/daiv/accounts/templates/accounts/api_keys.html b/daiv/accounts/templates/accounts/api_keys.html new file mode 100644 index 00000000..de4f1c43 --- /dev/null +++ b/daiv/accounts/templates/accounts/api_keys.html @@ -0,0 +1,121 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}API Keys — DAIV{% endblock %} + +{% block content %} +
+ +
+
+
+ + DAIV + +
+
+ {{ user.name|default:user.email }} + + Sign out + +
+
+
+ + +
+
+
+

API Keys

+

Create and manage keys for programmatic access.

+
+ + ← Dashboard + +
+ + {% if new_key %} + +
+
+ + + +
+

Your new API key

+

Copy it now — you won't be able to see it again.

+
+ {{ new_key }} + +
+
+
+
+ {% endif %} + + +
+ {% csrf_token %} +

Create a new key

+
+ + +
+
+ + +
+ {% if api_keys %} +
+ {% for key in api_keys %} +
+
+
+ {{ key.name }} + {% if key.revoked %} + Revoked + {% endif %} +
+
+ Prefix: {{ key.prefix }}... + · + Created {{ key.created|timesince }} ago + {% if key.expires_at %} + · + Expires {{ key.expires_at|date:"M j, Y" }} + {% endif %} +
+
+ {% if not key.revoked %} +
+ {% csrf_token %} + +
+ {% endif %} +
+ {% endfor %} +
+ {% else %} +
+

No API keys yet. Create one above to get started.

+
+ {% endif %} +
+
+
+{% endblock %} diff --git a/daiv/accounts/templates/accounts/dashboard.html b/daiv/accounts/templates/accounts/dashboard.html new file mode 100644 index 00000000..bec57ed8 --- /dev/null +++ b/daiv/accounts/templates/accounts/dashboard.html @@ -0,0 +1,79 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Dashboard — DAIV{% endblock %} + +{% block content %} +
+ +
+
+
+ DAIV +
+
+ {{ user.name|default:user.email }} + + Sign out + +
+
+
+ + +
+
+

Dashboard

+

Welcome back, {{ user.name|default:user.email }}

+
+ + +
+ {% for p in periods %} + + {{ p.label }} + + {% endfor %} +
+ + +
+ {% for counter in counters %} +
+

{{ counter.value }}

+

{{ counter.label }}

+
+ {% endfor %} +
+ + +
+
+{% endblock %} diff --git a/daiv/accounts/templates/base.html b/daiv/accounts/templates/base.html new file mode 100644 index 00000000..e41ac24d --- /dev/null +++ b/daiv/accounts/templates/base.html @@ -0,0 +1,42 @@ + + + + + + {% block title %}DAIV{% endblock %} + + + + {% load static %} + + + + {% if messages %} +
+ {% for message in messages %} +
+ {{ message }} +
+ {% endfor %} +
+ + {% endif %} + {% block content %}{% endblock %} + + diff --git a/daiv/accounts/templates/socialaccount/authentication_error.html b/daiv/accounts/templates/socialaccount/authentication_error.html new file mode 100644 index 00000000..d0fcaa44 --- /dev/null +++ b/daiv/accounts/templates/socialaccount/authentication_error.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Authentication error — DAIV{% endblock %} + +{% block content %} +
+
+ +
+
+ DAIV +
+ +
+
+ + + +
+

Authentication failed

+

+ Something went wrong while signing in. Please try again. +

+
+ + + Back to sign in + +
+
+{% endblock %} diff --git a/daiv/accounts/templates/socialaccount/login.html b/daiv/accounts/templates/socialaccount/login.html new file mode 100644 index 00000000..05f8753a --- /dev/null +++ b/daiv/accounts/templates/socialaccount/login.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Sign in via {{ provider.name }} — DAIV{% endblock %} + +{% block content %} +
+
+ +
+
+ DAIV +

+ Continue to {{ provider.name }} +

+
+ +
+ {% csrf_token %} + +
+ +

+ Back to sign in +

+
+
+{% endblock %} diff --git a/daiv/accounts/templates/socialaccount/login_cancelled.html b/daiv/accounts/templates/socialaccount/login_cancelled.html new file mode 100644 index 00000000..69fe5390 --- /dev/null +++ b/daiv/accounts/templates/socialaccount/login_cancelled.html @@ -0,0 +1,30 @@ +{% extends "base.html" %} +{% load static %} + +{% block title %}Sign in cancelled — DAIV{% endblock %} + +{% block content %} +
+
+ +
+
+ DAIV +
+ +
+

Sign in cancelled

+

+ You cancelled the sign in process. +

+
+ + + Back to sign in + +
+
+{% endblock %} diff --git a/daiv/accounts/views.py b/daiv/accounts/views.py new file mode 100644 index 00000000..7c39953e --- /dev/null +++ b/daiv/accounts/views.py @@ -0,0 +1,117 @@ +import logging +from datetime import timedelta + +from django.contrib import messages +from django.contrib.auth.mixins import LoginRequiredMixin +from django.db import IntegrityError +from django.db.models import Count, Q +from django.shortcuts import redirect +from django.utils import timezone +from django.views import View +from django.views.generic import TemplateView + +from django_tasks.base import TaskResultStatus +from django_tasks_db.models import DBTaskResult + +from accounts.forms import APIKeyCreateForm +from accounts.models import APIKey + +logger = logging.getLogger(__name__) + +ISSUE_TASK_PATH = "codebase.tasks.address_issue_task" +MR_TASK_PATH = "codebase.tasks.address_mr_comments_task" +TASK_PATHS = (ISSUE_TASK_PATH, MR_TASK_PATH) + +PERIOD_CHOICES = [("7d", "7 days", 7), ("30d", "30 days", 30), ("90d", "90 days", 90), ("all", "All time", None)] +PERIOD_DAYS = {key: days for key, _, days in PERIOD_CHOICES} +DEFAULT_PERIOD = "30d" + + +class DashboardView(LoginRequiredMixin, TemplateView): + template_name = "accounts/dashboard.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + period = self.request.GET.get("period", DEFAULT_PERIOD) + if period not in PERIOD_DAYS: + period = DEFAULT_PERIOD + days = PERIOD_DAYS[period] + + tasks = DBTaskResult.objects.filter(task_path__in=TASK_PATHS) + if days is not None: + tasks = tasks.filter(enqueued_at__gte=timezone.now() - timedelta(days=days)) + + successful = Q(status=TaskResultStatus.SUCCESSFUL) + code_changes = Q(return_value__code_changes=True) + stats = tasks.aggregate( + total=Count("id"), + successful=Count("id", filter=successful), + issues=Count("id", filter=successful & code_changes & Q(task_path=ISSUE_TASK_PATH)), + mrs=Count("id", filter=successful & code_changes & Q(task_path=MR_TASK_PATH)), + ) + active_api_keys = APIKey.objects.filter(user=self.request.user, revoked=False).count() + + total = stats["total"] + context["counters"] = [ + {"label": "Jobs processed", "value": total}, + {"label": "Success rate", "value": f"{round(stats['successful'] / total * 100)}%" if total else "—"}, + {"label": "Issues resolved", "value": stats["issues"]}, + {"label": "MRs assisted", "value": stats["mrs"]}, + {"label": "Active API keys", "value": active_api_keys}, + ] + context["periods"] = [{"key": key, "label": label} for key, label, _ in PERIOD_CHOICES] + context["current_period"] = period + return context + + +class APIKeyListView(LoginRequiredMixin, TemplateView): + template_name = "accounts/api_keys.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context["api_keys"] = APIKey.objects.filter(user=self.request.user).order_by("revoked", "-created") + context["new_key"] = self.request.session.pop("new_api_key", None) + context["form"] = APIKeyCreateForm() + return context + + +class APIKeyCreateView(LoginRequiredMixin, View): + def post(self, request): + form = APIKeyCreateForm(request.POST) + if not form.is_valid(): + for error in form.errors.values(): + messages.error(request, error[0]) + return redirect("api_keys") + + try: + key_generator = APIKey.objects.key_generator + key, prefix, hashed_key = key_generator.generate() + APIKey.objects.create( + user=request.user, name=form.cleaned_data["name"], prefix=prefix, hashed_key=hashed_key + ) + except IntegrityError: + messages.error(request, "Failed to create API key due to a conflict. Please try again.") + return redirect("api_keys") + except Exception: + logger.exception("Unexpected error creating API key for user %s", request.user.pk) + messages.error(request, "An unexpected error occurred. Please try again.") + return redirect("api_keys") + + request.session["new_api_key"] = key + messages.success(request, f"API key '{form.cleaned_data['name']}' created.") + return redirect("api_keys") + + +class APIKeyRevokeView(LoginRequiredMixin, View): + def post(self, request, pk): + api_key = APIKey.objects.filter(pk=pk, user=request.user).first() + if api_key is None: + messages.error(request, "API key not found.") + elif api_key.revoked: + messages.info(request, f"API key '{api_key.name}' was already revoked.") + else: + api_key.revoked = True + api_key.save(update_fields=["revoked"]) + messages.success(request, f"API key '{api_key.name}' revoked.") + return redirect("api_keys") diff --git a/daiv/automation/agent/middlewares/git.py b/daiv/automation/agent/middlewares/git.py index 30e64970..cdf232d2 100644 --- a/daiv/automation/agent/middlewares/git.py +++ b/daiv/automation/agent/middlewares/git.py @@ -12,7 +12,7 @@ from automation.agent.publishers import GitChangePublisher from codebase.base import MergeRequest, Scope from codebase.context import RuntimeCtx # noqa: TC001 -from codebase.utils import GitManager, GitPushPermissionError, get_repo_ref +from codebase.utils import GitManager, get_repo_ref if TYPE_CHECKING: from collections.abc import Awaitable, Callable @@ -58,6 +58,11 @@ class GitState(AgentState): The merge request used to commit the changes. """ + code_changes: Annotated[bool, PrivateStateAttr] + """ + Whether the agent produced code changes that were published to the repository. + """ + class GitMiddleware(AgentMiddleware[GitState, RuntimeCtx]): """ @@ -118,7 +123,7 @@ async def abefore_agent(self, state: GitState, runtime: Runtime[RuntimeCtx]) -> logger.warning("[%s] Failed to checkout to branch '%s': %s", self.name, merge_request.source_branch, e) merge_request = None - return {"merge_request": merge_request} + return {"merge_request": merge_request, "code_changes": False} async def awrap_model_call( self, request: ModelRequest[RuntimeCtx], handler: Callable[[ModelRequest[RuntimeCtx]], Awaitable[ModelResponse]] @@ -153,17 +158,13 @@ async def aafter_agent(self, state: GitState, runtime: Runtime[RuntimeCtx]) -> d return None publisher = GitChangePublisher(runtime.context) - try: - merge_request = await publisher.publish(merge_request=state.get("merge_request"), skip_ci=self.skip_ci) - except GitPushPermissionError as e: - logger.warning("[%s] Failed to publish changes due to git push permissions: %s", self.name, e) - return None + merge_request = await publisher.publish(merge_request=state.get("merge_request"), skip_ci=self.skip_ci) if merge_request: if runtime.context.scope == Scope.ISSUE and (rt := get_current_run_tree()): # If an issue resulted in a merge request, we send it to LangSmith for tracking. rt.metadata["merge_request_id"] = merge_request.merge_request_id - return {"merge_request": merge_request} + return {"merge_request": merge_request, "code_changes": True} return None diff --git a/daiv/codebase/managers/base.py b/daiv/codebase/managers/base.py index 2594a2b1..4ad0995d 100644 --- a/daiv/codebase/managers/base.py +++ b/daiv/codebase/managers/base.py @@ -1,13 +1,20 @@ +import logging from typing import TYPE_CHECKING from langgraph.store.memory import InMemoryStore +from automation.agent.publishers import GitChangePublisher from codebase.clients import RepoClient from codebase.utils import GitManager if TYPE_CHECKING: + from langchain.agents import CompiledAgent + from langchain_core.runnables import RunnableConfig + from codebase.context import RuntimeCtx +logger = logging.getLogger("daiv.managers") + class BaseManager: """ @@ -22,3 +29,40 @@ def __init__(self, *, runtime_ctx: RuntimeCtx): self.client = RepoClient.create_instance() self.store = InMemoryStore() self.git_manager = GitManager(self.ctx.gitrepo) + + async def _recover_draft( + self, agent: CompiledAgent, config: RunnableConfig, *, entity_label: str, entity_id: int | str + ) -> bool: + """ + Attempt to publish a draft MR from the agent's persisted state after an unexpected error. + + Returns: + Whether a draft merge request was successfully published. + """ + try: + snapshot = await agent.aget_state(config=config) + snapshot_mr = snapshot.values.get("merge_request") + + publisher = GitChangePublisher(self.ctx) + published_mr = await publisher.publish( + merge_request=snapshot_mr, as_draft=(snapshot_mr is None or snapshot_mr.draft) + ) + + if published_mr: + await agent.aupdate_state(config=config, values={"merge_request": published_mr}) + return True + except Exception: + logger.exception("Recovery failed after agent error for %s %s", entity_label, entity_id) + + return False + + @staticmethod + async def _read_code_changes(agent: CompiledAgent, config: RunnableConfig) -> dict[str, bool]: + """ + Read the ``code_changes`` flag from the agent's persisted state. + + ``code_changes`` is a PrivateStateAttr, so it's omitted from ainvoke output. + We read it from the persisted checkpoint instead. + """ + snapshot = await agent.aget_state(config=config) + return {"code_changes": bool(snapshot.values.get("code_changes"))} diff --git a/daiv/codebase/managers/issue_addressor.py b/daiv/codebase/managers/issue_addressor.py index 7a621c6e..44f01574 100644 --- a/daiv/codebase/managers/issue_addressor.py +++ b/daiv/codebase/managers/issue_addressor.py @@ -9,7 +9,6 @@ from langgraph.checkpoint.redis.aio import AsyncRedisSaver from automation.agent.graph import create_daiv_agent -from automation.agent.publishers import GitChangePublisher from automation.agent.utils import extract_text_content, get_daiv_agent_kwargs from codebase.base import GitPlatform from core.constants import BOT_NAME @@ -40,7 +39,9 @@ def __init__(self, *, issue: Issue, mention_comment_id: str | None = None, runti self.mention_comment_id = mention_comment_id @classmethod - async def address_issue(cls, *, issue: Issue, mention_comment_id: str | None = None, runtime_ctx: RuntimeCtx): + async def address_issue( + cls, *, issue: Issue, mention_comment_id: str | None = None, runtime_ctx: RuntimeCtx + ) -> dict[str, bool]: """ Address the issue. @@ -48,16 +49,20 @@ async def address_issue(cls, *, issue: Issue, mention_comment_id: str | None = N issue (Issue): The issue object. mention_comment_id (str | None): The mention comment id. Defaults to None. runtime_ctx (RuntimeCtx): The runtime context. + + Returns: + A dict with ``code_changes`` indicating whether code was published. """ manager = cls(issue=issue, mention_comment_id=mention_comment_id, runtime_ctx=runtime_ctx) try: - await manager._address_issue() + return await manager._address_issue() except Exception as e: logger.exception("Error addressing issue %d: %s", issue.iid, e) manager._add_unable_to_address_issue_note() + return {"code_changes": False} - async def _address_issue(self): + async def _address_issue(self) -> dict[str, bool]: """ Process the issue by addressing it with the appropriate actions. """ @@ -114,22 +119,11 @@ async def _address_issue(self): try: result = await daiv_agent.ainvoke({"messages": messages}, config=agent_config, context=self.ctx) except Exception: - snapshot = await daiv_agent.aget_state(config=agent_config) - - # If and unexpect error occurs while addressing the issue, a draft merge request is created to avoid - # losing the changes made by the agent. - snapshot_mr = snapshot.values.get("merge_request") - - publisher = GitChangePublisher(self.ctx) - published_mr = await publisher.publish( - merge_request=snapshot_mr, as_draft=(snapshot_mr is None or snapshot_mr.draft) + draft_published = await self._recover_draft( + daiv_agent, agent_config, entity_label="issue", entity_id=self.issue.iid ) - - # If the draft merge request is created successfully, we update the state to reflect the new MR. - if published_mr: - await daiv_agent.aupdate_state(config=agent_config, values={"merge_request": published_mr}) - - self._add_unable_to_address_issue_note(draft_published=bool(published_mr)) + self._add_unable_to_address_issue_note(draft_published=draft_published) + return {"code_changes": draft_published} else: if ( result @@ -141,6 +135,8 @@ async def _address_issue(self): else: self._add_unable_to_address_issue_note() + return await self._read_code_changes(daiv_agent, agent_config) + def _add_unable_to_address_issue_note(self, *, draft_published: bool = False): """ Add a note to the issue to inform the user that the response could not be generated. diff --git a/daiv/codebase/managers/review_addressor.py b/daiv/codebase/managers/review_addressor.py index f731c51d..23100917 100644 --- a/daiv/codebase/managers/review_addressor.py +++ b/daiv/codebase/managers/review_addressor.py @@ -13,7 +13,6 @@ from unidiff.patch import Line from automation.agent.graph import create_daiv_agent -from automation.agent.publishers import GitChangePublisher from automation.agent.utils import extract_text_content, get_daiv_agent_kwargs from codebase.base import GitPlatform, MergeRequest, Note, NoteDiffPosition, NoteDiffPositionType, NotePositionType from core.constants import BOT_NAME @@ -199,7 +198,9 @@ def __init__(self, *, merge_request: MergeRequest, mention_comment_id: str, runt ) @classmethod - async def address_comments(cls, *, merge_request: MergeRequest, mention_comment_id: str, runtime_ctx: RuntimeCtx): + async def address_comments( + cls, *, merge_request: MergeRequest, mention_comment_id: str, runtime_ctx: RuntimeCtx + ) -> dict[str, bool]: """ Process comments left directly on the merge request (not in the diff or thread) that mention DAIV. @@ -207,16 +208,20 @@ async def address_comments(cls, *, merge_request: MergeRequest, mention_comment_ merge_request (MergeRequest): The merge request. mention_comment_id (str): The mention comment id. runtime_ctx (RuntimeCtx): The runtime context. + + Returns: + A dict with ``code_changes`` indicating whether code was published. """ manager = cls(merge_request=merge_request, mention_comment_id=mention_comment_id, runtime_ctx=runtime_ctx) try: - await manager._address_comments() + return await manager._address_comments() except Exception: logger.exception("Error addressing comments for merge request: %d", merge_request.merge_request_id) manager._add_unable_to_address_review_note() + return {"code_changes": False} - async def _address_comments(self): + async def _address_comments(self) -> dict[str, bool]: """ Process comments left directly on the merge request (not in the diff or thread) that mention DAIV. """ @@ -263,20 +268,14 @@ async def _address_comments(self): context=self.ctx, ) except Exception: - snapshot = await daiv_agent.aget_state(config=agent_config) - - # If and unexpect error occurs while addressing the review, a draft merge request is created to avoid - # losing the changes made by the agent. - publisher = GitChangePublisher(self.ctx) - merge_request = snapshot.values.get("merge_request") - merge_request = await publisher.publish( - merge_request=merge_request, as_draft=(merge_request is None or merge_request.draft) + draft_published = await self._recover_draft( + daiv_agent, + agent_config, + entity_label="merge request", + entity_id=self.merge_request.merge_request_id, ) - - if merge_request: - await daiv_agent.aupdate_state(config=agent_config, values={"merge_request": merge_request}) - - self._add_unable_to_address_review_note(draft_published=bool(merge_request)) + self._add_unable_to_address_review_note(draft_published=draft_published) + return {"code_changes": draft_published} else: if ( result @@ -288,6 +287,8 @@ async def _address_comments(self): else: self._add_unable_to_address_review_note() + return await self._read_code_changes(daiv_agent, agent_config) + def _add_unable_to_address_review_note(self, *, draft_published: bool = False): """ Add a note to the merge request to inform the user that the review could not be addressed. diff --git a/daiv/codebase/tasks.py b/daiv/codebase/tasks.py index 78a00abb..a95cd95b 100644 --- a/daiv/codebase/tasks.py +++ b/daiv/codebase/tasks.py @@ -30,7 +30,7 @@ def setup_webhooks_cron_task(): @task(dedup=True) async def address_issue_task( repo_id: str, issue_iid: int, mention_comment_id: str | None = None, ref: str | None = None -): +) -> dict[str, bool]: """ Address an issue by creating a merge request with the changes described on the issue description. @@ -43,13 +43,13 @@ async def address_issue_task( client = RepoClient.create_instance() issue = client.get_issue(repo_id, issue_iid) async with set_runtime_ctx(repo_id, scope=Scope.ISSUE, ref=ref, issue=issue) as runtime_ctx: - await IssueAddressorManager.address_issue( + return await IssueAddressorManager.address_issue( issue=issue, mention_comment_id=mention_comment_id, runtime_ctx=runtime_ctx ) @task(dedup=True) -async def address_mr_comments_task(repo_id: str, merge_request_id: int, mention_comment_id: str): +async def address_mr_comments_task(repo_id: str, merge_request_id: int, mention_comment_id: str) -> dict[str, bool]: """ Address comments left directly on the merge request (not in the diff or thread) that mention DAIV. @@ -63,6 +63,6 @@ async def address_mr_comments_task(repo_id: str, merge_request_id: int, mention_ async with set_runtime_ctx( repo_id, scope=Scope.MERGE_REQUEST, ref=merge_request.source_branch, merge_request=merge_request ) as runtime_ctx: - await CommentsAddressorManager.address_comments( + return await CommentsAddressorManager.address_comments( merge_request=merge_request, mention_comment_id=mention_comment_id, runtime_ctx=runtime_ctx ) diff --git a/daiv/daiv/api.py b/daiv/daiv/api.py index 0436f5e1..c273d404 100644 --- a/daiv/daiv/api.py +++ b/daiv/daiv/api.py @@ -6,7 +6,7 @@ from . import __version__ -api = NinjaAPI(version=__version__, title="Daiv API", docs_url="/docs/") +api = NinjaAPI(version=__version__, title="Daiv API", docs_url="/docs/", urls_namespace="api") api.add_router("/codebase", codebase_router) api.add_router("/chat", chat_router) api.add_router("/models", models_router) diff --git a/daiv/daiv/settings/components/allauth.py b/daiv/daiv/settings/components/allauth.py new file mode 100644 index 00000000..64670447 --- /dev/null +++ b/daiv/daiv/settings/components/allauth.py @@ -0,0 +1,68 @@ +import logging + +from decouple import config +from get_docker_secret import get_docker_secret + +logger = logging.getLogger("daiv.settings") + +# --------------------------------------------------------------------------- +# django-allauth +# --------------------------------------------------------------------------- + +AUTHENTICATION_BACKENDS = [ + "django.contrib.auth.backends.ModelBackend", + "allauth.account.auth_backends.AuthenticationBackend", +] + +ACCOUNT_LOGIN_METHODS = {"email"} +ACCOUNT_EMAIL_REQUIRED = True +# Email verification is skipped because users authenticate via social providers +# (which verify emails) or via login-by-code (which proves email ownership). +ACCOUNT_EMAIL_VERIFICATION = "none" +ACCOUNT_LOGIN_BY_CODE_ENABLED = True +ACCOUNT_LOGIN_BY_CODE_MAX_ATTEMPTS = 3 +ACCOUNT_LOGIN_BY_CODE_TIMEOUT = 300 +ACCOUNT_ADAPTER = "accounts.adapter.AccountAdapter" +SOCIALACCOUNT_ADAPTER = "accounts.adapter.SocialAccountAdapter" +ACCOUNT_EMAIL_UNKNOWN_ACCOUNTS = False + +SOCIALACCOUNT_LOGIN_ON_GET = True +SOCIALACCOUNT_AUTO_SIGNUP = True +SOCIALACCOUNT_EMAIL_AUTHENTICATION = True +SOCIALACCOUNT_EMAIL_AUTHENTICATION_AUTO_CONNECT = True + +LOGIN_REDIRECT_URL = "/dashboard/" +ACCOUNT_LOGOUT_REDIRECT_URL = "/accounts/login/" +LOGIN_URL = "/accounts/login/" + +# Register social providers only when credentials are fully configured. +# This prevents rendering login buttons that lead to broken OAuth flows. +SOCIALACCOUNT_PROVIDERS = {} + + +def _register_provider(name, scope, app_config): + client_id = get_docker_secret(f"ALLAUTH_{name.upper()}_CLIENT_ID", default="") + secret = get_docker_secret(f"ALLAUTH_{name.upper()}_SECRET", default="") + if client_id and secret: + app = {"client_id": client_id, "secret": secret, **app_config} + SOCIALACCOUNT_PROVIDERS[name] = {"SCOPE": scope, "APPS": [app]} + elif bool(client_id) != bool(secret): + logger.warning( + "Partial %s OAuth config: set both ALLAUTH_%s_CLIENT_ID and ALLAUTH_%s_SECRET, or neither.", + name.capitalize(), + name.upper(), + name.upper(), + ) + + +_register_provider("github", scope=["user:email"], app_config={}) +_register_provider( + "gitlab", + scope=["read_user"], + app_config={ + "settings": { + "gitlab_url": config("ALLAUTH_GITLAB_URL", default="https://gitlab.com"), + "gitlab_server_url": config("ALLAUTH_GITLAB_SERVER_URL", default=""), + } + }, +) diff --git a/daiv/daiv/settings/components/common.py b/daiv/daiv/settings/components/common.py index 2662d643..c6bd6da3 100644 --- a/daiv/daiv/settings/components/common.py +++ b/daiv/daiv/settings/components/common.py @@ -1,3 +1,5 @@ +from pathlib import Path + from decouple import Csv, config from get_docker_secret import get_docker_secret @@ -11,24 +13,58 @@ LOCAL_APPS = ["accounts", "automation", "codebase", "core", "slash_commands"] -THIRD_PARTY_APPS = ["crontask", "django_extensions", "django_tasks", "django_tasks_db"] +THIRD_PARTY_APPS = [ + "crontask", + "django_extensions", + "django_tasks", + "django_tasks_db", + "allauth", + "allauth.account", + "allauth.socialaccount", + "allauth.socialaccount.providers.github", + "allauth.socialaccount.providers.gitlab", +] -DJANGO_APPS = ["django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions"] +DJANGO_APPS = [ + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.sites", + "django.contrib.messages", + "django.contrib.staticfiles", +] -# See: https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = LOCAL_APPS + THIRD_PARTY_APPS + DJANGO_APPS MIDDLEWARE = [ "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "allauth.account.middleware.AccountMiddleware", ] # TEMPLATE CONFIGURATION - https://docs.djangoproject.com/en/dev/ref/settings/#templates -TEMPLATES = [{"BACKEND": "django.template.backends.django.DjangoTemplates", "APP_DIRS": True}] +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ] + }, + } +] DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" @@ -38,7 +74,6 @@ # Password validation -# https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators AUTH_PASSWORD_VALIDATORS = [ {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator"}, @@ -76,3 +111,16 @@ # Use CSRF in session instead of cookie: https://docs.djangoproject.com/en/dev/ref/csrf/ CSRF_USE_SESSIONS = True X_FRAME_OPTIONS = "DENY" + + +# STATIC FILES - https://docs.djangoproject.com/en/dev/ref/settings/#static-files + +STATIC_URL = "/static/" +STATIC_ROOT = Path.home() / "data" / "static" +STORAGES = {"staticfiles": {"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage"}} + + +# EMAIL + +EMAIL_BACKEND = config("EMAIL_BACKEND", default="django.core.mail.backends.console.EmailBackend") +DEFAULT_FROM_EMAIL = config("DEFAULT_FROM_EMAIL", default="noreply@daiv.dev") diff --git a/daiv/daiv/settings/local.py b/daiv/daiv/settings/local.py index b5445c40..e27d12db 100644 --- a/daiv/daiv/settings/local.py +++ b/daiv/daiv/settings/local.py @@ -14,4 +14,5 @@ "components/logs.py", "components/debug.py", "components/tasks.py", + "components/allauth.py", ) diff --git a/daiv/daiv/settings/production.py b/daiv/daiv/settings/production.py index bccb0c61..10303ab2 100644 --- a/daiv/daiv/settings/production.py +++ b/daiv/daiv/settings/production.py @@ -8,4 +8,5 @@ "components/logs.py", "components/tasks.py", "components/sentry.py", + "components/allauth.py", ) diff --git a/daiv/daiv/settings/test.py b/daiv/daiv/settings/test.py index 1ae6b9df..e850c315 100644 --- a/daiv/daiv/settings/test.py +++ b/daiv/daiv/settings/test.py @@ -1,3 +1,9 @@ from split_settings.tools import include -include("components/common.py", "components/i18n.py", "components/tasks.py", "components/testing.py") +include( + "components/common.py", + "components/i18n.py", + "components/tasks.py", + "components/allauth.py", + "components/testing.py", +) diff --git a/daiv/daiv/urls.py b/daiv/daiv/urls.py index 164d6efe..6e7cebee 100644 --- a/daiv/daiv/urls.py +++ b/daiv/daiv/urls.py @@ -1,9 +1,12 @@ -from django.urls import path +from django.urls import include, path from core.views import HealthCheckView from daiv.api import api urlpatterns = [ - path(route="api/", view=api.urls), - path(route="-/alive/", view=HealthCheckView.as_view(), name="health_check"), + path("accounts/", include("accounts.allauth_urls")), + path("accounts/api-keys/", include("accounts.api_keys_urls")), + path("dashboard/", include("accounts.dashboard_urls")), + path("api/", api.urls), + path("-/alive/", HealthCheckView.as_view(), name="health_check"), ] diff --git a/docker/local/app/Dockerfile b/docker/local/app/Dockerfile index b20a532c..479ff9f4 100644 --- a/docker/local/app/Dockerfile +++ b/docker/local/app/Dockerfile @@ -1,5 +1,9 @@ +ARG TAILWIND_VERSION="v4.2.2" + FROM debian:bookworm-slim +ARG TAILWIND_VERSION + LABEL maintainer="srtabs@gmail.com" RUN apt-get update \ @@ -61,3 +65,9 @@ COPY --chown=app:app docker/local/app /home/app/docker RUN uv sync \ && mkdir -p /home/app/data + +# Install Tailwind CSS standalone CLI (v4) +RUN ARCH=$(dpkg --print-architecture | sed 's/amd64/x64/' | sed 's/arm64/arm64/') \ + && curl -sfLO "https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-${ARCH}" \ + && chmod +x "tailwindcss-linux-${ARCH}" \ + && mv "tailwindcss-linux-${ARCH}" /home/app/.local/bin/tailwindcss diff --git a/docker/local/app/config.env b/docker/local/app/config.env index 5476f9ed..e7b4bdbb 100644 --- a/docker/local/app/config.env +++ b/docker/local/app/config.env @@ -23,6 +23,10 @@ CODEBASE_GITLAB_URL=http://gitlab:8929 # CODEBASE_GITHUB_APP_ID= # CODEBASE_GITHUB_INSTALLATION_ID= +# Allauth +ALLAUTH_GITLAB_URL=http://127.0.0.1:8929 +ALLAUTH_GITLAB_SERVER_URL=http://gitlab:8929 + # LangSmith observability LANGCHAIN_TRACING_V2=true LANGCHAIN_PROJECT=default diff --git a/docker/local/app/config.secrets.env.example b/docker/local/app/config.secrets.env.example index f0124e22..31e13b92 100644 --- a/docker/local/app/config.secrets.env.example +++ b/docker/local/app/config.secrets.env.example @@ -29,3 +29,9 @@ CONTEXT7_API_KEY= # Sandbox secure execution (requires --profile sandbox) DAIV_SANDBOX_API_KEY= + +# Social auth (django-allauth) +ALLAUTH_GITHUB_CLIENT_ID= +ALLAUTH_GITHUB_SECRET= +ALLAUTH_GITLAB_CLIENT_ID= +ALLAUTH_GITLAB_SECRET= diff --git a/docker/local/app/start-app b/docker/local/app/start-app index 081b03fb..3f6fe192 100644 --- a/docker/local/app/start-app +++ b/docker/local/app/start-app @@ -14,6 +14,11 @@ fi django-admin compilemessages --ignore=.venv django-admin migrate --noinput +django-admin setup_default_site django-admin setup_checkpoint_saver -exec uvicorn --host 0.0.0.0 --port 8000 --ssl-keyfile "$CERT_DIR/cert.key" --ssl-certfile "$CERT_DIR/cert.crt" --reload --reload-dir 'daiv/' "daiv.asgi:application" +# Build Tailwind CSS and collect static files (use `make tailwind-watch` for hot reload) +tailwindcss -i daiv/accounts/static_src/css/input.css -o daiv/accounts/static/accounts/css/styles.css --minify +django-admin collectstatic --noinput + +exec uvicorn --host 0.0.0.0 --port 8000 --ssl-keyfile "$CERT_DIR/cert.key" --ssl-certfile "$CERT_DIR/cert.crt" --reload --reload-dir 'daiv/' --reload-include '*.html' --reload-include '*.css' "daiv.asgi:application" diff --git a/docker/production/app/Dockerfile b/docker/production/app/Dockerfile index 5ff674a5..4dc6ea2d 100644 --- a/docker/production/app/Dockerfile +++ b/docker/production/app/Dockerfile @@ -1,3 +1,5 @@ +ARG TAILWIND_VERSION="v4.2.2" + ######################################################################################################### # Python compile image ######################################################################################################### @@ -24,7 +26,7 @@ ENV UV_LINK_MODE=copy \ # Create a virtual environment and make it relocatable RUN uv venv .venv --relocatable -# Install uv +# Install Python dependencies RUN --mount=type=cache,target=/root/.cache/uv \ --mount=type=bind,source=uv.lock,target=uv.lock \ --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ @@ -35,6 +37,8 @@ RUN --mount=type=cache,target=/root/.cache/uv \ ######################################################################################################### FROM python:3.14.2-slim-bookworm AS python-builder +ARG TAILWIND_VERSION + LABEL maintainer="srtabs@gmail.com" RUN apt-get update \ @@ -91,7 +95,13 @@ WORKDIR /home/daiv RUN chmod +x entrypoint start-app start-worker start-crontask \ && python -m compileall app \ && django-admin compilemessages --ignore=.venv/**/locale \ - && mkdir -p data/media data/static + && mkdir -p data/media data/static \ + # Build Tailwind CSS + && ARCH=$(dpkg --print-architecture | sed 's/amd64/x64/' | sed 's/arm64/arm64/') \ + && curl -sfLO "https://github.com/tailwindlabs/tailwindcss/releases/download/${TAILWIND_VERSION}/tailwindcss-linux-${ARCH}" \ + && chmod +x "tailwindcss-linux-${ARCH}" \ + && ./"tailwindcss-linux-${ARCH}" -i app/accounts/static_src/css/input.css -o app/accounts/static/accounts/css/styles.css --minify \ + && rm "tailwindcss-linux-${ARCH}" HEALTHCHECK --interval=10s --start-period=30s \ CMD curl --fail http://127.0.0.1:8000/-/alive/ || exit 1 diff --git a/docker/production/app/start-app b/docker/production/app/start-app index c9d13dfe..213313b2 100644 --- a/docker/production/app/start-app +++ b/docker/production/app/start-app @@ -3,6 +3,8 @@ set -eu django-admin migrate --noinput +django-admin setup_default_site django-admin setup_checkpoint_saver +django-admin collectstatic --noinput exec uvicorn --host ${UVICORN_HOST:-0.0.0.0} --port ${UVICORN_PORT:-8000} --lifespan off --app-dir "/home/daiv/app" "daiv.asgi:application" diff --git a/docs/getting-started/deployment.md b/docs/getting-started/deployment.md index d9f0f4d2..bf4e1324 100644 --- a/docs/getting-started/deployment.md +++ b/docs/getting-started/deployment.md @@ -43,6 +43,10 @@ This guide walks you through deploying DAIV using Docker Swarm or Docker Compose * **`codebase_gitlab_webhook_secret`** - Random secret for GitLab webhook validation * **`daiv_sandbox_api_key`** - Random API key for Sandbox service authentication * **`openrouter_api_key`** - [OpenRouter API key](https://openrouter.ai/settings/keys) for LLM access +* **`allauth_github_client_id`** - GitHub OAuth App client ID (see [Authentication](../reference/env-variables.md#authentication)) +* **`allauth_github_secret`** - GitHub OAuth App secret +* **`allauth_gitlab_client_id`** - GitLab OAuth Application ID (see [Authentication](../reference/env-variables.md#authentication)) +* **`allauth_gitlab_secret`** - GitLab OAuth Application secret **Create each secret using this command** (see [Docker Secrets documentation](https://docs.docker.com/reference/cli/docker/secret/create/) for more details): @@ -147,6 +151,10 @@ services: - codebase_gitlab_webhook_secret - daiv_sandbox_api_key - openrouter_api_key + - allauth_github_client_id + - allauth_github_secret + - allauth_gitlab_client_id + - allauth_gitlab_secret networks: - internal - external @@ -282,6 +290,14 @@ secrets: external: true sentry_access_token: external: true + allauth_github_client_id: + external: true + allauth_github_secret: + external: true + allauth_gitlab_client_id: + external: true + allauth_gitlab_secret: + external: true ``` @@ -370,6 +386,11 @@ x-app-defaults: &x_app_default OPENROUTER_API_KEY: openrouter-api-key (8) # Sandbox settings DAIV_SANDBOX_API_KEY: daiv-sandbox-api-key (9) + # Authentication (at least one social provider recommended) + ALLAUTH_GITHUB_CLIENT_ID: github-client-id + ALLAUTH_GITHUB_SECRET: github-secret + ALLAUTH_GITLAB_CLIENT_ID: gitlab-client-id + ALLAUTH_GITLAB_SECRET: gitlab-secret services: db: @@ -628,7 +649,7 @@ server { systemctl restart nginx ``` -**Verify the configuration** by accessing your domain in a web browser. You should see the DAIV interface. +**Verify the configuration** by accessing your domain in a web browser. You should see the DAIV login page at `https://your-domain/accounts/login/`. --- diff --git a/docs/reference/env-variables.md b/docs/reference/env-variables.md index 2d899386..536f8fd0 100644 --- a/docs/reference/env-variables.md +++ b/docs/reference/env-variables.md @@ -110,6 +110,29 @@ Variables marked with: !!! note "Global policy vs. repository policy" `DAIV_SANDBOX_COMMAND_POLICY_DISALLOW` and `DAIV_SANDBOX_COMMAND_POLICY_ALLOW` set global defaults. Per-repository overrides are defined in the `.daiv.yml` `sandbox.command_policy` section and are merged at evaluation time. Built-in safety rules (blocking `git commit`, `git push`, etc.) cannot be overridden by either mechanism. +### Authentication + +DAIV uses [django-allauth](https://docs.allauth.org/) for web authentication. Users sign in via social providers (GitHub, GitLab) or passwordless login-by-code for existing accounts. Configure at least one social provider. + +| Variable | Description | Default | Example | +|-------------------------|------------------------------------|:--------------:|-----------------| +| `ALLAUTH_GITHUB_CLIENT_ID` :material-lock: | GitHub OAuth App client ID | *(none)* | `Iv1.abc123` | +| `ALLAUTH_GITHUB_SECRET` :material-lock: | GitHub OAuth App secret | *(none)* | | +| `ALLAUTH_GITLAB_CLIENT_ID` :material-lock: | GitLab OAuth Application ID | *(none)* | | +| `ALLAUTH_GITLAB_SECRET` :material-lock: | GitLab OAuth Application secret | *(none)* | | +| `ALLAUTH_GITLAB_URL` | GitLab instance URL (for OAuth redirects to the user's browser) | `https://gitlab.com` | `https://gitlab.example.com` | +| `ALLAUTH_GITLAB_SERVER_URL` | GitLab server URL (for server-to-server API calls, if different from `ALLAUTH_GITLAB_URL`) | *(none)* | `http://gitlab:8929` | +| `EMAIL_BACKEND` | Django email backend for login-by-code emails | `django.core.mail.backends.console.EmailBackend` | `django.core.mail.backends.smtp.EmailBackend` | +| `DEFAULT_FROM_EMAIL` | Sender address for login-by-code emails | `noreply@daiv.dev` | `noreply@example.com` | + +!!! info "Setting up social providers" + **GitHub**: Create an OAuth App at [github.com/settings/developers](https://github.com/settings/developers). Set the callback URL to `https:///accounts/github/login/callback/`. + + **GitLab**: Create an Application in your GitLab instance under **Admin Area → Applications** or **User Settings → Applications**. Set the redirect URI to `https:///accounts/gitlab/login/callback/` with the `read_user` scope. + +!!! note + Social providers are only registered when **both** client ID and secret are set. If only one is configured, a warning is logged and the provider button is not shown on the login page. + ### Other | Variable | Description | Default | Example | @@ -117,7 +140,7 @@ Variables marked with: | `DAIV_EXTERNAL_URL` | External URL of the application. | `https://app:8000` | `https://daiv.example.com` | !!! note - The `DAIV_EXTERNAL_URL` variable is used to define webhooks on Git platform. Make sure that the URL is accessible from the Git platform. + The `DAIV_EXTERNAL_URL` variable is used to define webhooks on Git platform and as the site domain for authentication emails. Make sure that the URL is accessible from the Git platform. --- diff --git a/pyproject.toml b/pyproject.toml index a50144eb..3fa7a73b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ dependencies = [ "ddgs==9.11.4", "deepagents==0.4.12", "django==6.0.3", + "django-allauth[socialaccount]==65.4.0", "django-crontask==1.1.3", "django-extensions==4.1.0", "django-ninja==1.6.2", @@ -53,6 +54,7 @@ dependencies = [ "sentry-sdk==2.56.0", "unidiff==0.7.5", "uvicorn[standard]==0.42.0", + "whitenoise==6.9.0", ] urls.changelog = "https://github.com/srtab/daiv/blob/main/CHANGELOG.md" urls.issues = "https://github.com/srtab/daiv/issues" diff --git a/tests/unit_tests/accounts/test_views.py b/tests/unit_tests/accounts/test_views.py new file mode 100644 index 00000000..7c7f0c8b --- /dev/null +++ b/tests/unit_tests/accounts/test_views.py @@ -0,0 +1,105 @@ +from django.contrib.messages import get_messages +from django.test import Client +from django.urls import reverse + +import pytest + +from accounts.models import APIKey, User + + +@pytest.fixture +def user(db): + return User.objects.create_user(username="alice", email="alice@test.com", password="testpass123") # noqa: S106 + + +@pytest.fixture +def other_user(db): + return User.objects.create_user(username="bob", email="bob@test.com", password="testpass456") # noqa: S106 + + +@pytest.fixture +def logged_in_client(user): + client = Client() + client.force_login(user) + return client + + +def _create_api_key(user, name="test-key"): + gen = APIKey.objects.key_generator + key, prefix, hashed_key = gen.generate() + api_key = APIKey.objects.create(user=user, name=name, prefix=prefix, hashed_key=hashed_key) + return api_key, key + + +@pytest.mark.django_db +class TestAPIKeyCreateView: + def test_create_stores_key_in_session(self, logged_in_client, user): + response = logged_in_client.post(reverse("api_key_create"), {"name": "my-key"}) + assert response.status_code == 302 + assert response.url == reverse("api_keys") + + api_key = APIKey.objects.get(user=user) + assert api_key.name == "my-key" + assert not api_key.revoked + + # The raw key was stored in the session so it can be shown once on the list page. + session = logged_in_client.session + assert api_key.prefix in session["new_api_key"] + + def test_create_multiple_keys(self, logged_in_client, user): + logged_in_client.post(reverse("api_key_create"), {"name": "key-1"}) + logged_in_client.post(reverse("api_key_create"), {"name": "key-2"}) + + assert APIKey.objects.filter(user=user).count() == 2 + + def test_create_requires_login(self): + client = Client() + response = client.post(reverse("api_key_create"), {"name": "my-key"}) + assert response.status_code == 302 + assert "/accounts/login/" in response.url + + +@pytest.mark.django_db +class TestAPIKeyRevokeView: + def test_revoke_own_key(self, logged_in_client, user): + api_key, _ = _create_api_key(user) + + response = logged_in_client.post(reverse("api_key_revoke", kwargs={"pk": api_key.pk})) + assert response.status_code == 302 + + api_key.refresh_from_db() + assert api_key.revoked + + def test_cannot_revoke_other_users_key(self, logged_in_client, other_user): + api_key, _ = _create_api_key(other_user, name="bob-key") + + response = logged_in_client.post(reverse("api_key_revoke", kwargs={"pk": api_key.pk})) + assert response.status_code == 302 + + api_key.refresh_from_db() + assert not api_key.revoked + + msgs = list(get_messages(response.wsgi_request)) + assert any("not found" in str(m).lower() for m in msgs) + + def test_revoke_already_revoked_key(self, logged_in_client, user): + api_key, _ = _create_api_key(user) + api_key.revoked = True + api_key.save(update_fields=["revoked"]) + + response = logged_in_client.post(reverse("api_key_revoke", kwargs={"pk": api_key.pk})) + assert response.status_code == 302 + + msgs = list(get_messages(response.wsgi_request)) + assert any("already revoked" in str(m).lower() for m in msgs) + + def test_revoke_requires_login(self, user): + api_key, _ = _create_api_key(user) + + client = Client() + response = client.post(reverse("api_key_revoke", kwargs={"pk": api_key.pk})) + assert response.status_code == 302 + assert "/accounts/login/" in response.url + + api_key.refresh_from_db() + assert not api_key.revoked diff --git a/tests/unit_tests/automation/agent/middlewares/test_git.py b/tests/unit_tests/automation/agent/middlewares/test_git.py index 564f9911..b00d60fc 100644 --- a/tests/unit_tests/automation/agent/middlewares/test_git.py +++ b/tests/unit_tests/automation/agent/middlewares/test_git.py @@ -1,5 +1,7 @@ from unittest.mock import AsyncMock, Mock, patch +import pytest + from automation.agent.middlewares.git import GitMiddleware from codebase.base import Scope from codebase.utils import GitPushPermissionError @@ -13,14 +15,15 @@ def _make_runtime(*, scope: Scope = Scope.ISSUE) -> Mock: class TestGitMiddleware: - async def test_aafter_agent_returns_none_when_publish_fails_with_push_permission_error(self): + async def test_aafter_agent_propagates_push_permission_error(self): middleware = GitMiddleware() runtime = _make_runtime() - with patch( - "automation.agent.middlewares.git.GitChangePublisher.publish", - new=AsyncMock(side_effect=GitPushPermissionError("No permission to push")), + with ( + patch( + "automation.agent.middlewares.git.GitChangePublisher.publish", + new=AsyncMock(side_effect=GitPushPermissionError("No permission to push")), + ), + pytest.raises(GitPushPermissionError), ): - result = await middleware.aafter_agent(state={"merge_request": None}, runtime=runtime) - - assert result is None + await middleware.aafter_agent(state={"merge_request": None}, runtime=runtime) diff --git a/uv.lock b/uv.lock index c965ac3a..a25bd6e7 100644 --- a/uv.lock +++ b/uv.lock @@ -416,6 +416,7 @@ dependencies = [ { name = "ddgs" }, { name = "deepagents" }, { name = "django" }, + { name = "django-allauth", extra = ["socialaccount"] }, { name = "django-crontask" }, { name = "django-extensions" }, { name = "django-ninja" }, @@ -447,6 +448,7 @@ dependencies = [ { name = "sentry-sdk" }, { name = "unidiff" }, { name = "uvicorn", extra = ["standard"] }, + { name = "whitenoise" }, ] [package.dev-dependencies] @@ -482,6 +484,7 @@ requires-dist = [ { name = "ddgs", specifier = "==9.11.4" }, { name = "deepagents", specifier = "==0.4.12" }, { name = "django", specifier = "==6.0.3" }, + { name = "django-allauth", extras = ["socialaccount"], specifier = "==65.4.0" }, { name = "django-crontask", specifier = "==1.1.3" }, { name = "django-extensions", specifier = "==4.1.0" }, { name = "django-ninja", specifier = "==1.6.2" }, @@ -513,6 +516,7 @@ requires-dist = [ { name = "sentry-sdk", specifier = "==2.56.0" }, { name = "unidiff", specifier = "==0.7.5" }, { name = "uvicorn", extras = ["standard"], specifier = "==0.42.0" }, + { name = "whitenoise", specifier = "==6.9.0" }, ] [package.metadata.requires-dev] @@ -652,6 +656,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/72/b1/23f2556967c45e34d3d3cf032eb1bd3ef925ee458667fb99052a0b3ea3a6/django-6.0.3-py3-none-any.whl", hash = "sha256:2e5974441491ddb34c3f13d5e7a9f97b07ba03bf70234c0a9c68b79bbb235bc3", size = 8358527, upload-time = "2026-03-03T13:55:10.552Z" }, ] +[[package]] +name = "django-allauth" +version = "65.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "asgiref" }, + { name = "django" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b4/f0/8aca7cc8c63b432565472e231ace2edfe3966304884f0ab106362e586ff8/django_allauth-65.4.0.tar.gz", hash = "sha256:1b6eb5d4a781a9a18dcd5ccf2bcae7ae542a56baea8e7b8f33a54c743cb54b0a", size = 1559056, upload-time = "2025-02-06T09:29:42.93Z" } + +[package.optional-dependencies] +socialaccount = [ + { name = "pyjwt", extra = ["crypto"] }, + { name = "requests" }, + { name = "requests-oauthlib" }, +] + [[package]] name = "django-crontask" version = "1.1.3" @@ -931,6 +952,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, @@ -939,6 +961,7 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, @@ -1884,6 +1907,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" }, ] +[[package]] +name = "oauthlib" +version = "3.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" }, +] + [[package]] name = "openai" version = "2.30.0" @@ -2766,6 +2798,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/56/5d/c814546c2333ceea4ba42262d8c4d55763003e767fa169adc693bd524478/requests-2.33.0-py3-none-any.whl", hash = "sha256:3324635456fa185245e24865e810cecec7b4caf933d7eb133dcde67d48cee69b", size = 65017, upload-time = "2026-03-25T15:10:40.382Z" }, ] +[[package]] +name = "requests-oauthlib" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "oauthlib" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" }, +] + [[package]] name = "requests-toolbelt" version = "1.0.0" @@ -3359,6 +3404,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] +[[package]] +name = "whitenoise" +version = "6.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/cf/c15c2f21aee6b22a9f6fc9be3f7e477e2442ec22848273db7f4eb73d6162/whitenoise-6.9.0.tar.gz", hash = "sha256:8c4a7c9d384694990c26f3047e118c691557481d624f069b7f7752a2f735d609", size = 25920, upload-time = "2025-02-06T22:16:34.957Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b2/2ce9263149fbde9701d352bda24ea1362c154e196d2fda2201f18fc585d7/whitenoise-6.9.0-py3-none-any.whl", hash = "sha256:c8a489049b7ee9889617bb4c274a153f3d979e8f51d2efd0f5b403caf41c57df", size = 20161, upload-time = "2025-02-06T22:16:32.589Z" }, +] + [[package]] name = "wrapt" version = "2.1.2"