diff --git a/backend/apps/github/common.py b/backend/apps/github/common.py index dba28e96dd..31ac4a23a4 100644 --- a/backend/apps/github/common.py +++ b/backend/apps/github/common.py @@ -1,9 +1,13 @@ """GitHub app common module.""" import logging +import os from datetime import timedelta as td +from urllib.parse import urlparse +from django.core.exceptions import ValidationError from django.utils import timezone +from github import Github from github.GithubException import UnknownObjectException from apps.github.models.issue import Issue @@ -16,9 +20,72 @@ from apps.github.models.user import User from apps.github.utils import check_owasp_site_repository +FETCH_ISSUE_ERROR = "Failed to fetch issue from GitHub: {error}" +MIN_PARTS_LENGTH = 4 +VALID_PROJECT_ERROR = ( + "Issue does not have a valid project and cannot be considered for sponsorship." +) + + logger = logging.getLogger(__name__) +def sync_issue(issue_link): + """Sync GitHub issue data.""" + try: + return Issue.objects.get(url=issue_link) + except Issue.DoesNotExist: + pass + + parsed_url = urlparse(issue_link) + path_parts = parsed_url.path.strip("/").split("/") + + github_token = os.getenv("GITHUB_TOKEN") + github_client = Github(github_token) + + issue_number = int(path_parts[3]) + owner = path_parts[0] + repo_name = path_parts[1] + + try: + gh_repo = github_client.get_repo(f"{owner}/{repo_name}") + gh_issue = gh_repo.get_issue(issue_number) + except Exception as error: + raise ValidationError(FETCH_ISSUE_ERROR.format(error=error)) from error + + try: + author = User.objects.get(login=gh_issue.user.login) + except User.DoesNotExist: + author = User.update_data(gh_issue.user) + + try: + repository = Repository.objects.get(node_id=gh_repo.id) + except Repository.DoesNotExist: + try: + repo_owner = User.objects.get(login=gh_repo.owner.login) + except User.DoesNotExist: + repo_owner = User.update_data(gh_repo.owner) + try: + organization = Organization.objects.get(node_id=gh_repo.organization.id) + except Organization.DoesNotExist: + organization = Organization.update_data(gh_repo.organization) + + repository = Repository.update_data( + gh_repository=gh_repo, + commits=gh_repo.get_commits(), + contributors=gh_repo.get_contributors(), + languages=gh_repo.get_languages(), + organization=organization, + user=repo_owner, + ) + + if not repository.project: + logger.exception(VALID_PROJECT_ERROR) + return None + + return Issue.update_data(gh_issue, author=author, repository=repository) + + def sync_repository(gh_repository, organization=None, user=None): """Sync GitHub repository data. diff --git a/backend/apps/nest/__init__.py b/backend/apps/nest/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/apps/nest/admin.py b/backend/apps/nest/admin.py new file mode 100644 index 0000000000..2be62e5cdd --- /dev/null +++ b/backend/apps/nest/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from apps.nest.models.sponsorship import Sponsorship + + +class SponsorshipAdmin(admin.ModelAdmin): + list_display = ("issue", "amount", "slack_user_id") + search_fields = ("issue__title", "slack_user_id") + + +admin.site.register(Sponsorship, SponsorshipAdmin) diff --git a/backend/apps/nest/apps.py b/backend/apps/nest/apps.py new file mode 100644 index 0000000000..67dc9f3f45 --- /dev/null +++ b/backend/apps/nest/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class NestConfig(AppConfig): + name = "apps.nest" diff --git a/backend/apps/nest/migrations/0001_initial.py b/backend/apps/nest/migrations/0001_initial.py new file mode 100644 index 0000000000..3a7f88ac43 --- /dev/null +++ b/backend/apps/nest/migrations/0001_initial.py @@ -0,0 +1,54 @@ +# Generated by Django 5.1.5 on 2025-03-16 07:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("github", "0016_user_is_bot"), + ] + + operations = [ + migrations.CreateModel( + name="Sponsorship", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("nest_created_at", models.DateTimeField(auto_now_add=True)), + ("nest_updated_at", models.DateTimeField(auto_now=True)), + ( + "currency", + models.CharField( + choices=[("EUR", "Euro"), ("USD", "US Dollar")], + default="USD", + max_length=3, + ), + ), + ("deadline_at", models.DateTimeField(blank=True, null=True)), + ("amount", models.FloatField()), + ("slack_user_id", models.CharField(max_length=100)), + ( + "issue", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sponsorships", + to="github.issue", + ), + ), + ], + options={ + "verbose_name_plural": "Sponsorships", + "db_table": "nest_sponsorships", + }, + ), + ] diff --git a/backend/apps/nest/migrations/__init__.py b/backend/apps/nest/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/apps/nest/models/__init__.py b/backend/apps/nest/models/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/apps/nest/models/sponsorship.py b/backend/apps/nest/models/sponsorship.py new file mode 100644 index 0000000000..f66c454fb3 --- /dev/null +++ b/backend/apps/nest/models/sponsorship.py @@ -0,0 +1,74 @@ +"""Nest app sponsorship model.""" + +from django.core.exceptions import ValidationError +from django.db import models +from django.utils import timezone + +from apps.common.models import BulkSaveModel, TimestampedModel +from apps.github.models.issue import Issue + +DEADLINE_FUTURE_ERROR = "Deadline must be in the future." +ISSUE_LINK_ERROR = "Issue link must belong to an OWASP repository." +PRICE_POSITIVE_ERROR = "Price must be a positive value." + + +class Sponsorship(BulkSaveModel, TimestampedModel): + """Sponsorship model.""" + + class CurrencyType(models.TextChoices): + """Currency type choices.""" + + EUR = "EUR", "Euro" + USD = "USD", "US Dollar" + + class Meta: + db_table = "nest_sponsorships" + verbose_name_plural = "Sponsorships" + + currency = models.CharField( + max_length=3, choices=CurrencyType.choices, default=CurrencyType.USD + ) + deadline_at = models.DateTimeField(null=True, blank=True) + amount = models.FloatField() + slack_user_id = models.CharField(max_length=100) + + issue = models.ForeignKey( + Issue, + on_delete=models.CASCADE, + related_name="sponsorships", + ) + + def __str__(self): + """Sponsorship human readable representation.""" + return f"Sponsorship for {self.issue.title} by {self.slack_user_id}" + + def clean(self): + """Validate model data.""" + super().clean() + + # Validate amount is positive + if self.amount <= 0: + raise ValidationError(PRICE_POSITIVE_ERROR) + + # Validate deadline is in the future + if self.deadline_at and self.deadline_at < timezone.now(): + raise ValidationError(DEADLINE_FUTURE_ERROR) + + # Validate GitHub issue link belongs to OWASP + if not self.issue.url.startswith("https://github.com/OWASP"): + raise ValidationError(ISSUE_LINK_ERROR) + + def save(self, *args, **kwargs): + """Override save to run full validation.""" + self.full_clean() + super().save(*args, **kwargs) + + @staticmethod + def update_data(sponsorship, **kwargs): + """Update sponsorship data with the provided fields.""" + fields_to_update = ["amount", "deadline_at", "slack_user_id"] + for field in fields_to_update: + if field in kwargs: + setattr(sponsorship, field, kwargs[field]) + sponsorship.save() + return sponsorship diff --git a/backend/apps/slack/commands/sponsor.py b/backend/apps/slack/commands/sponsor.py index 6f7037730e..72bdaad0a1 100644 --- a/backend/apps/slack/commands/sponsor.py +++ b/backend/apps/slack/commands/sponsor.py @@ -1,14 +1,31 @@ """Slack bot sponsors command.""" +import logging +from urllib.parse import urlparse + +from dateutil.parser import parse as date_parse from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import transaction +from django.utils import timezone from apps.common.constants import NL from apps.slack.apps import SlackConfig -from apps.slack.blocks import markdown from apps.slack.utils import get_text +logger = logging.getLogger(__name__) + COMMAND = "/sponsor" +COMMAND_FORMAT_ERROR = ( + "Invalid command format. Usage: `/sponsor task add [EUR|USD] [deadline]`" +) +DEADLINE_FORMAT_ERROR = "Invalid deadline format. Use YYYY-MM-DD or YYYY-MM-DD HH:MM." +INVALID_ISSUE_LINK_FORMAT = "Invalid GitHub issue link format." +DATE_INDEX = 4 +MIN_PARTS_LENGTH = 4 +TIME_INDEX = 5 + def sponsor_handler(ack, command, client): """Handle the Slack /sponsor command. @@ -19,14 +36,114 @@ def sponsor_handler(ack, command, client): client (slack_sdk.WebClient): The Slack WebClient instance for API calls. """ + from apps.github.common import sync_issue + from apps.nest.models.sponsorship import Sponsorship + ack() if not settings.SLACK_COMMANDS_ENABLED: return - blocks = [ - markdown(f"Coming soon...{NL}"), - ] + text = command.get("text", "") + if text.startswith("task add"): + try: + parts = text.split() + if len(parts) < MIN_PARTS_LENGTH: + logger.error(COMMAND_FORMAT_ERROR) + return + + issue_link = parts[2] + price_input = parts[3] + + currency = Sponsorship.CurrencyType.USD + price = price_input + + if price_input.endswith("EUR"): + currency = "EUR" + price = price_input[:-3] + elif price_input.endswith("USD"): + currency = "USD" + price = price_input[:-3] + + parsed_url = urlparse(issue_link) + path_parts = parsed_url.path.strip("/").split("/") + if len(path_parts) < MIN_PARTS_LENGTH or path_parts[2] != "issues": + logger.error("Invalid GitHub issue link format") + return + + deadline = None + if len(parts) > DATE_INDEX: + deadline_str = " ".join(parts[DATE_INDEX:]) + try: + deadline = date_parse(deadline_str).replace( + tzinfo=timezone.get_current_timezone() + ) + except ValueError as e: + raise ValidationError(DEADLINE_FORMAT_ERROR) from e + + with transaction.atomic(): + issue = sync_issue(issue_link) + sponsorship, created = Sponsorship.objects.get_or_create( + issue=issue, + defaults={ + "amount": price, + "currency": currency, + "deadline_at": deadline, + "slack_user_id": command["user_id"], + }, + ) + + if not created: + sponsorship.amount = price + sponsorship.currency = currency + sponsorship.deadline_at = deadline + sponsorship.slack_user_id = command["user_id"] + sponsorship.save() + + blocks = get_sponsorship_blocks(sponsorship) + except ValidationError as e: + logger.exception("Validation error") + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"❌ *Error:* {e!s}{NL}", + }, + } + ] + except Exception: + logger.exception("Validation error") + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": ( + f"❌ *Error:* An error occurred while processing your request.{NL}" + ), + }, + } + ] + else: + usage_text = ( + f"*Usage:* `/sponsor task add [EUR|USD] [deadline]`{NL}" + f"Example: `/sponsor task add https://github.com/ORG/Repo/issues/XYZ " + f"100USD 2025-12-31`{NL}" + f"Example with EUR: `/sponsor task add https://github.com/ORG/Repo/issues/XYZ " + f"100EUR 2025-12-31`{NL}" + f"Example with time: `/sponsor task add https://github.com/ORG/Repo/" + f"issues/XYZ 100 2025-12-31 23:59`" + ) + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": usage_text, + }, + } + ] conversation = client.conversations_open(users=command["user_id"]) client.chat_postMessage( @@ -38,3 +155,25 @@ def sponsor_handler(ack, command, client): if SlackConfig.app: sponsor_handler = SlackConfig.app.command(COMMAND)(sponsor_handler) + + +def get_sponsorship_blocks(sponsorship): + """Generate Slack blocks for the sponsorship confirmation message.""" + currency_symbol = "€" if sponsorship.currency == "EUR" else "$" + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"🎉 *Sponsorship created successfully!* 🎉{NL}" + f"*Issue:* {sponsorship.issue.title}{NL}" + f"*Price:* {currency_symbol}{sponsorship.amount} ({sponsorship.currency}){NL}" + f"*Created by:* <@{sponsorship.slack_user_id}>{NL}", + }, + } + ] + if sponsorship.deadline_at: + blocks[0]["text"]["text"] += ( + f"*Deadline:* {sponsorship.deadline_at.strftime('%Y-%m-%d %H:%M')}{NL}" + ) + return blocks diff --git a/backend/settings/base.py b/backend/settings/base.py index b7800cc978..257a5df086 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -46,6 +46,7 @@ class Base(Configuration): "apps.common", "apps.core", "apps.github", + "apps.nest", "apps.owasp", "apps.slack", ) diff --git a/backend/tests/nest/model/__init__.py b/backend/tests/nest/model/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/tests/nest/model/sponsorship_test.py b/backend/tests/nest/model/sponsorship_test.py new file mode 100644 index 0000000000..e3f45790cb --- /dev/null +++ b/backend/tests/nest/model/sponsorship_test.py @@ -0,0 +1,136 @@ +from unittest.mock import Mock, patch + +import pytest +from django.utils import timezone + +from apps.github.models.issue import Issue +from apps.nest.models.sponsorship import Sponsorship + +PRICE_USD = 100.0 + + +class TestSponsorshipModel: + @pytest.mark.parametrize( + ("amount", "deadline_at", "slack_user_id"), + [ + (100.0, timezone.now(), "U12345"), + (200.0, None, "U67890"), + ], + ) + @patch.object(Sponsorship, "save") + def test_update_data(self, mock_save, amount, deadline_at, slack_user_id): + """Test the update_data method of the Sponsorship model.""" + sponsorship = Sponsorship() + + updated_sponsorship = Sponsorship.update_data( + sponsorship, + amount=amount, + deadline_at=deadline_at, + slack_user_id=slack_user_id, + ) + + assert updated_sponsorship.amount == amount + assert updated_sponsorship.deadline_at == deadline_at + assert updated_sponsorship.slack_user_id == slack_user_id + mock_save.assert_called_once() + + @patch.object(Sponsorship, "save") + def test_update_data_save_called(self, mock_save): + """Test that the save method is called when updating sponsorship data.""" + sponsorship = Sponsorship() + + Sponsorship.update_data( + sponsorship, + amount=PRICE_USD, + deadline_at=timezone.now(), + slack_user_id="U12345", + ) + + mock_save.assert_called_once() + + @pytest.mark.parametrize( + ("initial_price", "updated_price", "initial_deadline", "updated_deadline"), + [ + (50.0, 100.0, timezone.now(), timezone.now()), + (200.0, 150.0, None, timezone.now()), + ], + ) + @patch.object(Sponsorship, "save") + def test_update_data_partial_updates( + self, mock_save, initial_price, updated_price, initial_deadline, updated_deadline + ): + """Test partial updates using the update_data method.""" + sponsorship = Sponsorship(amount=initial_price, deadline_at=initial_deadline) + + Sponsorship.update_data(sponsorship, amount=updated_price) + assert sponsorship.amount == updated_price + assert sponsorship.deadline_at == initial_deadline + mock_save.assert_called_once() + + mock_save.reset_mock() + + # Update only the deadline + Sponsorship.update_data(sponsorship, deadline_at=updated_deadline) + assert sponsorship.amount == updated_price + assert sponsorship.deadline_at == updated_deadline + mock_save.assert_called_once() + + @patch("apps.nest.models.sponsorship.Sponsorship.objects.create") + def test_sponsorship_creation(self, mock_create): + """Test creating a Sponsorship instance in the database.""" + issue = Mock(spec=Issue, title="Test Issue", url="https://github.com/OWASP/Nest/issues/1") + issue._state = Mock() + + mock_create.return_value = Sponsorship( + issue=issue, + amount=PRICE_USD, + slack_user_id="U12345", + ) + + sponsorship = Sponsorship.objects.create( + issue=issue, + amount=100.0, + slack_user_id="U12345", + ) + + assert sponsorship.issue == issue + assert sponsorship.amount == PRICE_USD + assert sponsorship.slack_user_id == "U12345" + assert sponsorship.deadline_at is None + mock_create.assert_called_once_with( + issue=issue, + amount=100.0, + slack_user_id="U12345", + ) + + @patch("apps.nest.models.sponsorship.Sponsorship.objects.create") + def test_sponsorship_with_deadline(self, mock_create): + """Test creating a Sponsorship instance with a deadline.""" + # Mock the Issue instance with _state attribute + issue = Mock(spec=Issue, title="Test Issue", url="https://github.com/OWASP/Nest/issues/1") + issue._state = Mock() + + deadline = timezone.now() + + # Mock the return value of Sponsorship.objects.create + mock_create.return_value = Sponsorship( + issue=issue, + amount=100.0, + slack_user_id="U12345", + deadline_at=deadline, + ) + + sponsorship = Sponsorship.objects.create( + issue=issue, + amount=100.0, + slack_user_id="U12345", + deadline_at=deadline, + ) + + assert sponsorship.deadline_at == deadline + mock_create.assert_called_once_with( + issue=issue, + amount=100.0, + slack_user_id="U12345", + deadline_at=deadline, + )