From cd28fee967303a0452eddc871f3aed64eb536382 Mon Sep 17 00:00:00 2001 From: Abhay Mishra Date: Thu, 27 Feb 2025 20:47:10 +0530 Subject: [PATCH 1/7] Added nest app and slack command for sponsor --- backend/apps/nest/__init__.py | 0 backend/apps/nest/admin.py | 11 ++ backend/apps/nest/apps.py | 5 + backend/apps/nest/migrations/0001_initial.py | 46 +++++++ backend/apps/nest/migrations/__init__.py | 0 backend/apps/nest/models/__init__.py | 0 backend/apps/nest/models/sponsorship.py | 38 ++++++ backend/apps/slack/commands/sponsor.py | 130 ++++++++++++++++++- backend/apps/slack/utils.py | 100 ++++++++++++++ backend/settings/base.py | 2 + 10 files changed, 327 insertions(+), 5 deletions(-) create mode 100644 backend/apps/nest/__init__.py create mode 100644 backend/apps/nest/admin.py create mode 100644 backend/apps/nest/apps.py create mode 100644 backend/apps/nest/migrations/0001_initial.py create mode 100644 backend/apps/nest/migrations/__init__.py create mode 100644 backend/apps/nest/models/__init__.py create mode 100644 backend/apps/nest/models/sponsorship.py 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..5a76aa2e7a --- /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", "price_usd", "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..b23a75ead7 --- /dev/null +++ b/backend/apps/nest/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Django 5.1.5 on 2025-02-27 05:33 + +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)), + ("deadline_at", models.DateTimeField(blank=True, null=True)), + ("price_usd", 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..400cb560d5 --- /dev/null +++ b/backend/apps/nest/models/sponsorship.py @@ -0,0 +1,38 @@ +"""Nest app sponsorship model.""" + +from django.db import models + +from apps.common.models import BulkSaveModel, TimestampedModel +from apps.github.models.issue import Issue + + +class Sponsorship(BulkSaveModel, TimestampedModel): + """Sponsorship model.""" + + deadline_at = models.DateTimeField(null=True, blank=True) + price_usd = models.FloatField() + slack_user_id = models.CharField(max_length=100) + + issue = models.ForeignKey( + Issue, + on_delete=models.CASCADE, + related_name="sponsorships", + ) + + class Meta: + db_table = "nest_sponsorships" + verbose_name_plural = "Sponsorships" + + def __str__(self): + """Sponsorship human readable representation.""" + return f"Sponsorship for {self.issue.title} by {self.slack_user_id}" + + @staticmethod + def update_data(sponsorship, **kwargs): + """Update sponsorship data with the provided fields.""" + fields_to_update = ["price_usd", "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 c80a432a57..04992e75cb 100644 --- a/backend/apps/slack/commands/sponsor.py +++ b/backend/apps/slack/commands/sponsor.py @@ -1,25 +1,124 @@ """Slack bot sponsors command.""" +import logging + from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import transaction 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 +from apps.slack.utils import ( + get_or_create_issue, + get_text, + validate_deadline, + validate_github_issue_link, + validate_price, +) + +logger = logging.getLogger(__name__) COMMAND = "/sponsor" +COMMAND_FORMAT_ERROR = ( + "Invalid command format. Usage: `/sponsor task add [deadline]`" +) +DATE_INDEX = 4 +MIN_PARTS_LENGTH = 4 +TIME_INDEX = 5 + def sponsor_handler(ack, command, client): """Slack /sponsor command handler.""" + from apps.nest.models.sponsorship import Sponsorship + ack() if not settings.SLACK_COMMANDS_ENABLED: return - blocks = [ - markdown(f"Coming soon...{NL}"), - ] + def validate_command_format(parts): + if len(parts) < MIN_PARTS_LENGTH: + raise ValidationError(COMMAND_FORMAT_ERROR) + + text = command.get("text", "") + if text.startswith("task add"): + try: + parts = text.split() + validate_command_format(parts) + + issue_link = parts[2] + price = parts[3] + + deadline_str = None + if len(parts) > DATE_INDEX: + deadline_str = parts[DATE_INDEX] + if len(parts) > TIME_INDEX: + deadline_str += " " + parts[TIME_INDEX] + + validate_github_issue_link(issue_link) + validated_price = validate_price(price) + deadline = validate_deadline(deadline_str) if deadline_str else None + + with transaction.atomic(): + issue = get_or_create_issue(issue_link) + sponsorship, created = Sponsorship.objects.get_or_create( + issue=issue, + defaults={ + "price_usd": validated_price, + "slack_user_id": command["user_id"], + "deadline_at": deadline, + }, + ) + + if not created: + sponsorship.price_usd = validated_price + sponsorship.slack_user_id = command["user_id"] + sponsorship.deadline_at = deadline + 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 [deadline]`{NL}" + f"Example: `/sponsor task add https://github.com/ORG/Repo/issues/XYZ" + f"100 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( @@ -31,3 +130,24 @@ 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.""" + blocks = [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": f"🎉 *Sponsorship created successfully!* 🎉{NL}" + f"*Issue:* {sponsorship.issue.title}{NL}" + f"*Price:* ${sponsorship.price_usd}{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/apps/slack/utils.py b/backend/apps/slack/utils.py index 79c63b349d..e2f07ddddf 100644 --- a/backend/apps/slack/utils.py +++ b/backend/apps/slack/utils.py @@ -2,12 +2,17 @@ import logging import re +from datetime import datetime from functools import lru_cache from html import escape as escape_html from urllib.parse import urljoin import requests import yaml +from django.conf import settings +from django.core.exceptions import ValidationError +from django.utils import timezone +from github import Github from lxml import html from requests.exceptions import RequestException @@ -16,6 +21,19 @@ logger = logging.getLogger(__name__) +ISSUES_INDEX = 5 +GITHUB_COM_INDEX = 2 +MIN_PARTS_LENGTH = 4 + +DEADLINE_FORMAT_ERROR = "Invalid deadline format. Use YYYY-MM-DD or YYYY-MM-DD HH:MM." +DEADLINE_FUTURE_ERROR = "Deadline must be in the future." +FETCH_ISSUE_ERROR = "Failed to fetch issue from GitHub: {error}" +INVALID_ISSUE_LINK_FORMAT = "Invalid GitHub issue link format." +ISSUE_LINK_ERROR = "Issue link must belong to an OWASP repository." +PRICE_POSITIVE_ERROR = "Price must be a positive value." +PRICE_VALID_ERROR = "Price must be a valid number." + + def escape(content): """Escape HTML content.""" return escape_html(content, quote=False) @@ -65,6 +83,48 @@ def get_news_data(limit=10, timeout=30): return items +def get_or_create_issue(issue_link): + """Fetch or create an Issue instance from the GitHub API.""" + from apps.github.models.issue import Issue + from apps.github.models.repository import Repository + from apps.github.models.user import User + + logger.info("Fetching or creating issue for link: %s", issue_link) + + # Extract repository owner, repo name, and issue number from the issue link + # Example: https://github.com/OWASP/Nest/issues/XYZ + parts = issue_link.strip("/").split("/") + if ( + len(parts) < MIN_PARTS_LENGTH + or parts[GITHUB_COM_INDEX] != "github.com" + or parts[ISSUES_INDEX] != "issues" + ): + raise ValidationError(INVALID_ISSUE_LINK_FORMAT) + + try: + return Issue.objects.get(url=issue_link) + except Issue.DoesNotExist: + pass + + github_client = Github(settings.GITHUB_TOKEN) + issue_number = int(parts[6]) + owner = parts[3] + repo_name = parts[4] + + try: + # Fetch the repository and issue from GitHub + gh_repo = github_client.get_repo(f"{owner}/{repo_name}") + gh_issue = gh_repo.get_issue(issue_number) + repository = Repository.objects.get(name=repo_name) + author = User.objects.get(login=gh_issue.user.login) + + # Update or create the issue in the database + return Issue.update_data(gh_issue, author=author, repository=repository) + except Exception as e: + logger.exception("Failed to fetch issue from GitHub: %s") + raise ValidationError(FETCH_ISSUE_ERROR.format(error=e)) from e + + @lru_cache def get_staff_data(timeout=30): """Get staff data.""" @@ -132,3 +192,43 @@ def strip_markdown(text): """Strip markdown formatting.""" slack_link_pattern = re.compile(r"<(https?://[^|]+)\|([^>]+)>") return slack_link_pattern.sub(r"\2 (\1)", text).replace("*", "") + + +def validate_deadline(deadline_str): + """Validate that the deadline is in a valid datetime format.""" + try: + # Try parsing the deadline in YYYY-MM-DD format + deadline = datetime.strptime(deadline_str, "%Y-%m-%d").replace( + tzinfo=timezone.get_current_timezone() + ) + except ValueError: + try: + # Try parsing the deadline in YYYY-MM-DD HH:MM format + deadline = datetime.strptime(deadline_str, "%Y-%m-%d %H:%M").replace( + tzinfo=timezone.get_current_timezone() + ) + except ValueError as e: + raise ValidationError(DEADLINE_FORMAT_ERROR) from e + + if deadline < timezone.now(): + raise ValidationError(DEADLINE_FUTURE_ERROR) + + return deadline + + +def validate_github_issue_link(issue_link): + """Validate that the issue link belongs to a valid OWASP-related repository.""" + if not issue_link.startswith("https://github.com/OWASP"): + raise ValidationError(ISSUE_LINK_ERROR) + return issue_link + + +def validate_price(price): + """Validate that the price is a positive float value.""" + try: + price = float(price) + if price <= 0: + raise ValidationError(PRICE_POSITIVE_ERROR) + except ValueError as e: + raise ValidationError(PRICE_VALID_ERROR) from e + return price diff --git a/backend/settings/base.py b/backend/settings/base.py index ec348b4e17..6fdc356fdb 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -19,6 +19,7 @@ class Base(Configuration): ALLOWED_HOSTS = values.ListValue() DEBUG = False + GITHUB_TOKEN = values.Value(environ_name="GITHUB_TOKEN") RELEASE_VERSION = values.Value(environ_name="RELEASE_VERSION") SENTRY_DSN = values.SecretValue(environ_name="SENTRY_DSN") SITE_NAME = "localhost" @@ -45,6 +46,7 @@ class Base(Configuration): "apps.common", "apps.core", "apps.github", + "apps.nest", "apps.owasp", "apps.slack", ) From e7f54b15fbe718bfb2c5f69bb11543d42e566e8f Mon Sep 17 00:00:00 2001 From: Abhay Mishra Date: Fri, 28 Feb 2025 02:44:25 +0530 Subject: [PATCH 2/7] Added test for sponsership model --- backend/tests/nest/model/__init__.py | 0 backend/tests/nest/model/sponsorship_test.py | 136 +++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 backend/tests/nest/model/__init__.py create mode 100644 backend/tests/nest/model/sponsorship_test.py 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..ecd165bf5e --- /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( + ("price_usd", "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, price_usd, deadline_at, slack_user_id): + """Test the update_data method of the Sponsorship model.""" + sponsorship = Sponsorship() + + updated_sponsorship = Sponsorship.update_data( + sponsorship, + price_usd=price_usd, + deadline_at=deadline_at, + slack_user_id=slack_user_id, + ) + + assert updated_sponsorship.price_usd == price_usd + 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, + price_usd=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(price_usd=initial_price, deadline_at=initial_deadline) + + Sponsorship.update_data(sponsorship, price_usd=updated_price) + assert sponsorship.price_usd == 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.price_usd == 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, + price_usd=PRICE_USD, + slack_user_id="U12345", + ) + + sponsorship = Sponsorship.objects.create( + issue=issue, + price_usd=100.0, + slack_user_id="U12345", + ) + + assert sponsorship.issue == issue + assert sponsorship.price_usd == PRICE_USD + assert sponsorship.slack_user_id == "U12345" + assert sponsorship.deadline_at is None + mock_create.assert_called_once_with( + issue=issue, + price_usd=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, + price_usd=100.0, + slack_user_id="U12345", + deadline_at=deadline, + ) + + sponsorship = Sponsorship.objects.create( + issue=issue, + price_usd=100.0, + slack_user_id="U12345", + deadline_at=deadline, + ) + + assert sponsorship.deadline_at == deadline + mock_create.assert_called_once_with( + issue=issue, + price_usd=100.0, + slack_user_id="U12345", + deadline_at=deadline, + ) From faf996845c828132bc9776805fc22e6b0d55b66d Mon Sep 17 00:00:00 2001 From: Abhay Mishra Date: Sat, 1 Mar 2025 13:05:43 +0530 Subject: [PATCH 3/7] Resolved as per suggestions --- backend/apps/github/common.py | 59 ++++++++++++++ backend/apps/github/models/issue.py | 2 +- backend/apps/nest/constants.py | 13 +++ backend/apps/nest/models/sponsorship.py | 59 +++++++++++++- backend/apps/slack/commands/sponsor.py | 17 ++-- backend/apps/slack/utils.py | 100 ------------------------ backend/settings/base.py | 1 - 7 files changed, 134 insertions(+), 117 deletions(-) create mode 100644 backend/apps/nest/constants.py diff --git a/backend/apps/github/common.py b/backend/apps/github/common.py index a275d71928..d7c4ff6265 100644 --- a/backend/apps/github/common.py +++ b/backend/apps/github/common.py @@ -1,7 +1,11 @@ """GitHub app common module.""" import logging +import os +from urllib.parse import urlparse +from django.core.exceptions import ValidationError +from github import Github from github.GithubException import UnknownObjectException from apps.github.models.issue import Issue @@ -13,9 +17,64 @@ from apps.github.models.user import User from apps.github.utils import check_owasp_site_repository +INVALID_ISSUE_LINK_FORMAT = "Invalid GitHub issue link format." +FETCH_ISSUE_ERROR = "Failed to fetch issue from GitHub: {error}" +MIN_PARTS_LENGTH = 4 + + 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("/") + if len(path_parts) < MIN_PARTS_LENGTH or path_parts[2] != "issues": + raise ValidationError(INVALID_ISSUE_LINK_FORMAT) + + 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] + + gh_repo = github_client.get_repo(f"{owner}/{repo_name}") + gh_issue = gh_repo.get_issue(issue_number) + 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: + owner = User.objects.get(login=gh_repo.owner.login) + except User.DoesNotExist: + 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=owner, + ) + + return Issue.update_data(gh_issue, author=author, repository=repository) + + def sync_repository(gh_repository, organization=None, user=None): """Sync GitHub repository data.""" entity_key = gh_repository.name.lower() diff --git a/backend/apps/github/models/issue.py b/backend/apps/github/models/issue.py index cc94d4466b..6179d67887 100644 --- a/backend/apps/github/models/issue.py +++ b/backend/apps/github/models/issue.py @@ -151,7 +151,7 @@ def generate_summary(self, open_ai=None, max_tokens=500): prompt = ( Prompt.get_github_issue_documentation_project_summary() - if self.project.is_documentation_type + if self.project and self.project.is_documentation_type else Prompt.get_github_issue_project_summary() ) if not prompt: diff --git a/backend/apps/nest/constants.py b/backend/apps/nest/constants.py new file mode 100644 index 0000000000..32124a3551 --- /dev/null +++ b/backend/apps/nest/constants.py @@ -0,0 +1,13 @@ +"""Nest app constants.""" + +ISSUES_INDEX = 5 +GITHUB_COM_INDEX = 2 +MIN_PARTS_LENGTH = 4 + +DEADLINE_FORMAT_ERROR = "Invalid deadline format. Use YYYY-MM-DD or YYYY-MM-DD HH:MM." +DEADLINE_FUTURE_ERROR = "Deadline must be in the future." +FETCH_ISSUE_ERROR = "Failed to fetch issue from GitHub: {error}" +INVALID_ISSUE_LINK_FORMAT = "Invalid GitHub issue link format." +ISSUE_LINK_ERROR = "Issue link must belong to an OWASP repository." +PRICE_POSITIVE_ERROR = "Price must be a positive value." +PRICE_VALID_ERROR = "Price must be a valid number." diff --git a/backend/apps/nest/models/sponsorship.py b/backend/apps/nest/models/sponsorship.py index 400cb560d5..0b3a68403e 100644 --- a/backend/apps/nest/models/sponsorship.py +++ b/backend/apps/nest/models/sponsorship.py @@ -1,14 +1,29 @@ """Nest app sponsorship model.""" +from datetime import datetime + +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 +from apps.nest.constants import ( + DEADLINE_FORMAT_ERROR, + DEADLINE_FUTURE_ERROR, + ISSUE_LINK_ERROR, + PRICE_POSITIVE_ERROR, + PRICE_VALID_ERROR, +) class Sponsorship(BulkSaveModel, TimestampedModel): """Sponsorship model.""" + class Meta: + db_table = "nest_sponsorships" + verbose_name_plural = "Sponsorships" + deadline_at = models.DateTimeField(null=True, blank=True) price_usd = models.FloatField() slack_user_id = models.CharField(max_length=100) @@ -19,10 +34,6 @@ class Sponsorship(BulkSaveModel, TimestampedModel): related_name="sponsorships", ) - class Meta: - db_table = "nest_sponsorships" - verbose_name_plural = "Sponsorships" - def __str__(self): """Sponsorship human readable representation.""" return f"Sponsorship for {self.issue.title} by {self.slack_user_id}" @@ -36,3 +47,43 @@ def update_data(sponsorship, **kwargs): setattr(sponsorship, field, kwargs[field]) sponsorship.save() return sponsorship + + @staticmethod + def validate_deadline(deadline_str): + """Validate that the deadline is in a valid datetime format.""" + try: + # Try parsing the deadline in YYYY-MM-DD format + deadline = datetime.strptime(deadline_str, "%Y-%m-%d").replace( + tzinfo=timezone.get_current_timezone() + ) + except ValueError: + try: + # Try parsing the deadline in YYYY-MM-DD HH:MM format + deadline = datetime.strptime(deadline_str, "%Y-%m-%d %H:%M").replace( + tzinfo=timezone.get_current_timezone() + ) + except ValueError as e: + raise ValidationError(DEADLINE_FORMAT_ERROR) from e + + if deadline < timezone.now(): + raise ValidationError(DEADLINE_FUTURE_ERROR) + + return deadline + + @staticmethod + def validate_github_issue_link(issue_link): + """Validate that the issue link belongs to a valid OWASP-related repository.""" + if not issue_link.startswith("https://github.com/OWASP"): + raise ValidationError(ISSUE_LINK_ERROR) + return issue_link + + @staticmethod + def validate_price(price): + """Validate that the price is a positive float value.""" + try: + price = float(price) + if price <= 0: + raise ValidationError(PRICE_POSITIVE_ERROR) + except ValueError as e: + raise ValidationError(PRICE_VALID_ERROR) from e + return price diff --git a/backend/apps/slack/commands/sponsor.py b/backend/apps/slack/commands/sponsor.py index 04992e75cb..108ecde3f9 100644 --- a/backend/apps/slack/commands/sponsor.py +++ b/backend/apps/slack/commands/sponsor.py @@ -8,13 +8,7 @@ from apps.common.constants import NL from apps.slack.apps import SlackConfig -from apps.slack.utils import ( - get_or_create_issue, - get_text, - validate_deadline, - validate_github_issue_link, - validate_price, -) +from apps.slack.utils import get_text logger = logging.getLogger(__name__) @@ -30,6 +24,7 @@ def sponsor_handler(ack, command, client): """Slack /sponsor command handler.""" + from apps.github.common import sync_issue from apps.nest.models.sponsorship import Sponsorship ack() @@ -56,12 +51,12 @@ def validate_command_format(parts): if len(parts) > TIME_INDEX: deadline_str += " " + parts[TIME_INDEX] - validate_github_issue_link(issue_link) - validated_price = validate_price(price) - deadline = validate_deadline(deadline_str) if deadline_str else None + Sponsorship.validate_github_issue_link(issue_link) + validated_price = Sponsorship.validate_price(price) + deadline = Sponsorship.validate_deadline(deadline_str) if deadline_str else None with transaction.atomic(): - issue = get_or_create_issue(issue_link) + issue = sync_issue(issue_link) sponsorship, created = Sponsorship.objects.get_or_create( issue=issue, defaults={ diff --git a/backend/apps/slack/utils.py b/backend/apps/slack/utils.py index e2f07ddddf..79c63b349d 100644 --- a/backend/apps/slack/utils.py +++ b/backend/apps/slack/utils.py @@ -2,17 +2,12 @@ import logging import re -from datetime import datetime from functools import lru_cache from html import escape as escape_html from urllib.parse import urljoin import requests import yaml -from django.conf import settings -from django.core.exceptions import ValidationError -from django.utils import timezone -from github import Github from lxml import html from requests.exceptions import RequestException @@ -21,19 +16,6 @@ logger = logging.getLogger(__name__) -ISSUES_INDEX = 5 -GITHUB_COM_INDEX = 2 -MIN_PARTS_LENGTH = 4 - -DEADLINE_FORMAT_ERROR = "Invalid deadline format. Use YYYY-MM-DD or YYYY-MM-DD HH:MM." -DEADLINE_FUTURE_ERROR = "Deadline must be in the future." -FETCH_ISSUE_ERROR = "Failed to fetch issue from GitHub: {error}" -INVALID_ISSUE_LINK_FORMAT = "Invalid GitHub issue link format." -ISSUE_LINK_ERROR = "Issue link must belong to an OWASP repository." -PRICE_POSITIVE_ERROR = "Price must be a positive value." -PRICE_VALID_ERROR = "Price must be a valid number." - - def escape(content): """Escape HTML content.""" return escape_html(content, quote=False) @@ -83,48 +65,6 @@ def get_news_data(limit=10, timeout=30): return items -def get_or_create_issue(issue_link): - """Fetch or create an Issue instance from the GitHub API.""" - from apps.github.models.issue import Issue - from apps.github.models.repository import Repository - from apps.github.models.user import User - - logger.info("Fetching or creating issue for link: %s", issue_link) - - # Extract repository owner, repo name, and issue number from the issue link - # Example: https://github.com/OWASP/Nest/issues/XYZ - parts = issue_link.strip("/").split("/") - if ( - len(parts) < MIN_PARTS_LENGTH - or parts[GITHUB_COM_INDEX] != "github.com" - or parts[ISSUES_INDEX] != "issues" - ): - raise ValidationError(INVALID_ISSUE_LINK_FORMAT) - - try: - return Issue.objects.get(url=issue_link) - except Issue.DoesNotExist: - pass - - github_client = Github(settings.GITHUB_TOKEN) - issue_number = int(parts[6]) - owner = parts[3] - repo_name = parts[4] - - try: - # Fetch the repository and issue from GitHub - gh_repo = github_client.get_repo(f"{owner}/{repo_name}") - gh_issue = gh_repo.get_issue(issue_number) - repository = Repository.objects.get(name=repo_name) - author = User.objects.get(login=gh_issue.user.login) - - # Update or create the issue in the database - return Issue.update_data(gh_issue, author=author, repository=repository) - except Exception as e: - logger.exception("Failed to fetch issue from GitHub: %s") - raise ValidationError(FETCH_ISSUE_ERROR.format(error=e)) from e - - @lru_cache def get_staff_data(timeout=30): """Get staff data.""" @@ -192,43 +132,3 @@ def strip_markdown(text): """Strip markdown formatting.""" slack_link_pattern = re.compile(r"<(https?://[^|]+)\|([^>]+)>") return slack_link_pattern.sub(r"\2 (\1)", text).replace("*", "") - - -def validate_deadline(deadline_str): - """Validate that the deadline is in a valid datetime format.""" - try: - # Try parsing the deadline in YYYY-MM-DD format - deadline = datetime.strptime(deadline_str, "%Y-%m-%d").replace( - tzinfo=timezone.get_current_timezone() - ) - except ValueError: - try: - # Try parsing the deadline in YYYY-MM-DD HH:MM format - deadline = datetime.strptime(deadline_str, "%Y-%m-%d %H:%M").replace( - tzinfo=timezone.get_current_timezone() - ) - except ValueError as e: - raise ValidationError(DEADLINE_FORMAT_ERROR) from e - - if deadline < timezone.now(): - raise ValidationError(DEADLINE_FUTURE_ERROR) - - return deadline - - -def validate_github_issue_link(issue_link): - """Validate that the issue link belongs to a valid OWASP-related repository.""" - if not issue_link.startswith("https://github.com/OWASP"): - raise ValidationError(ISSUE_LINK_ERROR) - return issue_link - - -def validate_price(price): - """Validate that the price is a positive float value.""" - try: - price = float(price) - if price <= 0: - raise ValidationError(PRICE_POSITIVE_ERROR) - except ValueError as e: - raise ValidationError(PRICE_VALID_ERROR) from e - return price diff --git a/backend/settings/base.py b/backend/settings/base.py index 6fdc356fdb..893ab9712d 100644 --- a/backend/settings/base.py +++ b/backend/settings/base.py @@ -19,7 +19,6 @@ class Base(Configuration): ALLOWED_HOSTS = values.ListValue() DEBUG = False - GITHUB_TOKEN = values.Value(environ_name="GITHUB_TOKEN") RELEASE_VERSION = values.Value(environ_name="RELEASE_VERSION") SENTRY_DSN = values.SecretValue(environ_name="SENTRY_DSN") SITE_NAME = "localhost" From c448539c6f50d15d5976e97e7c060fd897d027f0 Mon Sep 17 00:00:00 2001 From: Abhay Mishra Date: Sat, 1 Mar 2025 13:33:22 +0530 Subject: [PATCH 4/7] Resolved cr suggestions --- backend/apps/github/common.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/backend/apps/github/common.py b/backend/apps/github/common.py index d7c4ff6265..6f7cefeaef 100644 --- a/backend/apps/github/common.py +++ b/backend/apps/github/common.py @@ -55,9 +55,9 @@ def sync_issue(issue_link): repository = Repository.objects.get(node_id=gh_repo.id) except Repository.DoesNotExist: try: - owner = User.objects.get(login=gh_repo.owner.login) + repo_owner = User.objects.get(login=gh_repo.owner.login) except User.DoesNotExist: - owner = User.update_data(gh_repo.owner) + repo_owner = User.update_data(gh_repo.owner) try: organization = Organization.objects.get(node_id=gh_repo.organization.id) except Organization.DoesNotExist: @@ -69,7 +69,7 @@ def sync_issue(issue_link): contributors=gh_repo.get_contributors(), languages=gh_repo.get_languages(), organization=organization, - user=owner, + user=repo_owner, ) return Issue.update_data(gh_issue, author=author, repository=repository) From c6c3ce7c4760a788ff7248d922b236d4180eb9b9 Mon Sep 17 00:00:00 2001 From: Abhay Mishra Date: Sat, 1 Mar 2025 14:26:40 +0530 Subject: [PATCH 5/7] Added suggestions by cr --- backend/apps/github/common.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/apps/github/common.py b/backend/apps/github/common.py index 6f7cefeaef..448936bf5d 100644 --- a/backend/apps/github/common.py +++ b/backend/apps/github/common.py @@ -44,8 +44,12 @@ def sync_issue(issue_link): owner = path_parts[0] repo_name = path_parts[1] - gh_repo = github_client.get_repo(f"{owner}/{repo_name}") - gh_issue = gh_repo.get_issue(issue_number) + 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: From 956c164ded0a5db57c25c5a45e13d852ec1a7cf2 Mon Sep 17 00:00:00 2001 From: Abhay Mishra Date: Sun, 16 Mar 2025 14:34:14 +0530 Subject: [PATCH 6/7] resolved suggestions --- backend/apps/github/common.py | 10 ++- backend/apps/github/models/issue.py | 2 +- backend/apps/nest/admin.py | 2 +- backend/apps/nest/constants.py | 13 --- backend/apps/nest/migrations/0001_initial.py | 12 ++- backend/apps/nest/models/sponsorship.py | 87 ++++++++------------ backend/apps/slack/commands/sponsor.py | 70 ++++++++++------ 7 files changed, 102 insertions(+), 94 deletions(-) delete mode 100644 backend/apps/nest/constants.py diff --git a/backend/apps/github/common.py b/backend/apps/github/common.py index 448936bf5d..706d42c4ca 100644 --- a/backend/apps/github/common.py +++ b/backend/apps/github/common.py @@ -17,9 +17,11 @@ from apps.github.models.user import User from apps.github.utils import check_owasp_site_repository -INVALID_ISSUE_LINK_FORMAT = "Invalid GitHub issue link format." 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__) @@ -34,8 +36,6 @@ def sync_issue(issue_link): parsed_url = urlparse(issue_link) path_parts = parsed_url.path.strip("/").split("/") - if len(path_parts) < MIN_PARTS_LENGTH or path_parts[2] != "issues": - raise ValidationError(INVALID_ISSUE_LINK_FORMAT) github_token = os.getenv("GITHUB_TOKEN") github_client = Github(github_token) @@ -76,6 +76,10 @@ def sync_issue(issue_link): user=repo_owner, ) + if not repository.project: + logger.exception(VALID_PROJECT_ERROR) + return None + return Issue.update_data(gh_issue, author=author, repository=repository) diff --git a/backend/apps/github/models/issue.py b/backend/apps/github/models/issue.py index 6179d67887..cc94d4466b 100644 --- a/backend/apps/github/models/issue.py +++ b/backend/apps/github/models/issue.py @@ -151,7 +151,7 @@ def generate_summary(self, open_ai=None, max_tokens=500): prompt = ( Prompt.get_github_issue_documentation_project_summary() - if self.project and self.project.is_documentation_type + if self.project.is_documentation_type else Prompt.get_github_issue_project_summary() ) if not prompt: diff --git a/backend/apps/nest/admin.py b/backend/apps/nest/admin.py index 5a76aa2e7a..2be62e5cdd 100644 --- a/backend/apps/nest/admin.py +++ b/backend/apps/nest/admin.py @@ -4,7 +4,7 @@ class SponsorshipAdmin(admin.ModelAdmin): - list_display = ("issue", "price_usd", "slack_user_id") + list_display = ("issue", "amount", "slack_user_id") search_fields = ("issue__title", "slack_user_id") diff --git a/backend/apps/nest/constants.py b/backend/apps/nest/constants.py deleted file mode 100644 index 32124a3551..0000000000 --- a/backend/apps/nest/constants.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Nest app constants.""" - -ISSUES_INDEX = 5 -GITHUB_COM_INDEX = 2 -MIN_PARTS_LENGTH = 4 - -DEADLINE_FORMAT_ERROR = "Invalid deadline format. Use YYYY-MM-DD or YYYY-MM-DD HH:MM." -DEADLINE_FUTURE_ERROR = "Deadline must be in the future." -FETCH_ISSUE_ERROR = "Failed to fetch issue from GitHub: {error}" -INVALID_ISSUE_LINK_FORMAT = "Invalid GitHub issue link format." -ISSUE_LINK_ERROR = "Issue link must belong to an OWASP repository." -PRICE_POSITIVE_ERROR = "Price must be a positive value." -PRICE_VALID_ERROR = "Price must be a valid number." diff --git a/backend/apps/nest/migrations/0001_initial.py b/backend/apps/nest/migrations/0001_initial.py index b23a75ead7..3a7f88ac43 100644 --- a/backend/apps/nest/migrations/0001_initial.py +++ b/backend/apps/nest/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.5 on 2025-02-27 05:33 +# Generated by Django 5.1.5 on 2025-03-16 07:43 import django.db.models.deletion from django.db import migrations, models @@ -26,8 +26,16 @@ class Migration(migrations.Migration): ), ("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)), - ("price_usd", models.FloatField()), + ("amount", models.FloatField()), ("slack_user_id", models.CharField(max_length=100)), ( "issue", diff --git a/backend/apps/nest/models/sponsorship.py b/backend/apps/nest/models/sponsorship.py index 0b3a68403e..f66c454fb3 100644 --- a/backend/apps/nest/models/sponsorship.py +++ b/backend/apps/nest/models/sponsorship.py @@ -1,31 +1,35 @@ """Nest app sponsorship model.""" -from datetime import datetime - 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 -from apps.nest.constants import ( - DEADLINE_FORMAT_ERROR, - DEADLINE_FUTURE_ERROR, - ISSUE_LINK_ERROR, - PRICE_POSITIVE_ERROR, - PRICE_VALID_ERROR, -) + +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) - price_usd = models.FloatField() + amount = models.FloatField() slack_user_id = models.CharField(max_length=100) issue = models.ForeignKey( @@ -38,52 +42,33 @@ 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 = ["price_usd", "deadline_at", "slack_user_id"] + 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 - - @staticmethod - def validate_deadline(deadline_str): - """Validate that the deadline is in a valid datetime format.""" - try: - # Try parsing the deadline in YYYY-MM-DD format - deadline = datetime.strptime(deadline_str, "%Y-%m-%d").replace( - tzinfo=timezone.get_current_timezone() - ) - except ValueError: - try: - # Try parsing the deadline in YYYY-MM-DD HH:MM format - deadline = datetime.strptime(deadline_str, "%Y-%m-%d %H:%M").replace( - tzinfo=timezone.get_current_timezone() - ) - except ValueError as e: - raise ValidationError(DEADLINE_FORMAT_ERROR) from e - - if deadline < timezone.now(): - raise ValidationError(DEADLINE_FUTURE_ERROR) - - return deadline - - @staticmethod - def validate_github_issue_link(issue_link): - """Validate that the issue link belongs to a valid OWASP-related repository.""" - if not issue_link.startswith("https://github.com/OWASP"): - raise ValidationError(ISSUE_LINK_ERROR) - return issue_link - - @staticmethod - def validate_price(price): - """Validate that the price is a positive float value.""" - try: - price = float(price) - if price <= 0: - raise ValidationError(PRICE_POSITIVE_ERROR) - except ValueError as e: - raise ValidationError(PRICE_VALID_ERROR) from e - return price diff --git a/backend/apps/slack/commands/sponsor.py b/backend/apps/slack/commands/sponsor.py index 108ecde3f9..812e850739 100644 --- a/backend/apps/slack/commands/sponsor.py +++ b/backend/apps/slack/commands/sponsor.py @@ -1,10 +1,13 @@ """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 @@ -15,8 +18,10 @@ COMMAND = "/sponsor" COMMAND_FORMAT_ERROR = ( - "Invalid command format. Usage: `/sponsor task add [deadline]`" + "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 @@ -32,44 +37,60 @@ def sponsor_handler(ack, command, client): if not settings.SLACK_COMMANDS_ENABLED: return - def validate_command_format(parts): - if len(parts) < MIN_PARTS_LENGTH: - raise ValidationError(COMMAND_FORMAT_ERROR) - text = command.get("text", "") if text.startswith("task add"): try: parts = text.split() - validate_command_format(parts) + if len(parts) < MIN_PARTS_LENGTH: + logger.error(COMMAND_FORMAT_ERROR) + return issue_link = parts[2] - price = parts[3] + price_input = parts[3] - deadline_str = None - if len(parts) > DATE_INDEX: - deadline_str = parts[DATE_INDEX] - if len(parts) > TIME_INDEX: - deadline_str += " " + parts[TIME_INDEX] + 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] - Sponsorship.validate_github_issue_link(issue_link) - validated_price = Sponsorship.validate_price(price) - deadline = Sponsorship.validate_deadline(deadline_str) if deadline_str else None + 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={ - "price_usd": validated_price, - "slack_user_id": command["user_id"], + "amount": price, + "currency": currency, "deadline_at": deadline, + "slack_user_id": command["user_id"], }, ) if not created: - sponsorship.price_usd = validated_price - sponsorship.slack_user_id = command["user_id"] + sponsorship.amount = price + sponsorship.currency = currency sponsorship.deadline_at = deadline + sponsorship.slack_user_id = command["user_id"] sponsorship.save() blocks = get_sponsorship_blocks(sponsorship) @@ -99,9 +120,11 @@ def validate_command_format(parts): ] else: usage_text = ( - f"*Usage:* `/sponsor task add [deadline]`{NL}" - f"Example: `/sponsor task add https://github.com/ORG/Repo/issues/XYZ" - f"100 2025-12-31`{NL}" + 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`" ) @@ -129,6 +152,7 @@ def validate_command_format(parts): def get_sponsorship_blocks(sponsorship): """Generate Slack blocks for the sponsorship confirmation message.""" + currency_symbol = "€" if sponsorship.currency == "EUR" else "$" blocks = [ { "type": "section", @@ -136,7 +160,7 @@ def get_sponsorship_blocks(sponsorship): "type": "mrkdwn", "text": f"🎉 *Sponsorship created successfully!* 🎉{NL}" f"*Issue:* {sponsorship.issue.title}{NL}" - f"*Price:* ${sponsorship.price_usd}{NL}" + f"*Price:* {currency_symbol}{sponsorship.amount} ({sponsorship.currency}){NL}" f"*Created by:* <@{sponsorship.slack_user_id}>{NL}", }, } From ebcf41030c87561aed5be6b41f60ff12bc08f633 Mon Sep 17 00:00:00 2001 From: Abhay Mishra Date: Sun, 16 Mar 2025 14:36:26 +0530 Subject: [PATCH 7/7] resolved test --- backend/tests/nest/model/sponsorship_test.py | 32 ++++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/backend/tests/nest/model/sponsorship_test.py b/backend/tests/nest/model/sponsorship_test.py index ecd165bf5e..e3f45790cb 100644 --- a/backend/tests/nest/model/sponsorship_test.py +++ b/backend/tests/nest/model/sponsorship_test.py @@ -11,25 +11,25 @@ class TestSponsorshipModel: @pytest.mark.parametrize( - ("price_usd", "deadline_at", "slack_user_id"), + ("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, price_usd, deadline_at, slack_user_id): + 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, - price_usd=price_usd, + amount=amount, deadline_at=deadline_at, slack_user_id=slack_user_id, ) - assert updated_sponsorship.price_usd == price_usd + 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() @@ -41,7 +41,7 @@ def test_update_data_save_called(self, mock_save): Sponsorship.update_data( sponsorship, - price_usd=PRICE_USD, + amount=PRICE_USD, deadline_at=timezone.now(), slack_user_id="U12345", ) @@ -60,10 +60,10 @@ 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(price_usd=initial_price, deadline_at=initial_deadline) + sponsorship = Sponsorship(amount=initial_price, deadline_at=initial_deadline) - Sponsorship.update_data(sponsorship, price_usd=updated_price) - assert sponsorship.price_usd == updated_price + Sponsorship.update_data(sponsorship, amount=updated_price) + assert sponsorship.amount == updated_price assert sponsorship.deadline_at == initial_deadline mock_save.assert_called_once() @@ -71,7 +71,7 @@ def test_update_data_partial_updates( # Update only the deadline Sponsorship.update_data(sponsorship, deadline_at=updated_deadline) - assert sponsorship.price_usd == updated_price + assert sponsorship.amount == updated_price assert sponsorship.deadline_at == updated_deadline mock_save.assert_called_once() @@ -83,23 +83,23 @@ def test_sponsorship_creation(self, mock_create): mock_create.return_value = Sponsorship( issue=issue, - price_usd=PRICE_USD, + amount=PRICE_USD, slack_user_id="U12345", ) sponsorship = Sponsorship.objects.create( issue=issue, - price_usd=100.0, + amount=100.0, slack_user_id="U12345", ) assert sponsorship.issue == issue - assert sponsorship.price_usd == PRICE_USD + 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, - price_usd=100.0, + amount=100.0, slack_user_id="U12345", ) @@ -115,14 +115,14 @@ def test_sponsorship_with_deadline(self, mock_create): # Mock the return value of Sponsorship.objects.create mock_create.return_value = Sponsorship( issue=issue, - price_usd=100.0, + amount=100.0, slack_user_id="U12345", deadline_at=deadline, ) sponsorship = Sponsorship.objects.create( issue=issue, - price_usd=100.0, + amount=100.0, slack_user_id="U12345", deadline_at=deadline, ) @@ -130,7 +130,7 @@ def test_sponsorship_with_deadline(self, mock_create): assert sponsorship.deadline_at == deadline mock_create.assert_called_once_with( issue=issue, - price_usd=100.0, + amount=100.0, slack_user_id="U12345", deadline_at=deadline, )