diff --git a/backend/Makefile b/backend/Makefile index 818edbb3d8..4eb7cddf29 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -78,6 +78,14 @@ owasp-process-snapshots: @echo "Processing OWASP snapshots" @CMD="python manage.py owasp_process_snapshots" $(MAKE) exec-backend-command +owasp-update-project-health-metrics: + @echo "Updating OWASP project health requirements" + @CMD="python manage.py owasp_update_project_health_metrics" $(MAKE) exec-backend-command + +owasp-update-project-health-requirements: + @echo "Updating OWASP project health metrics" + @CMD="python manage.py owasp_update_project_health_requirements" $(MAKE) exec-backend-command + owasp-scrape-chapters: @echo "Scraping OWASP site chapters data" @CMD="python manage.py owasp_scrape_chapters" $(MAKE) exec-backend-command diff --git a/backend/apps/owasp/admin.py b/backend/apps/owasp/admin.py index ec7391d518..51db63fcd9 100644 --- a/backend/apps/owasp/admin.py +++ b/backend/apps/owasp/admin.py @@ -7,6 +7,8 @@ from apps.owasp.models.committee import Committee from apps.owasp.models.event import Event from apps.owasp.models.project import Project +from apps.owasp.models.project_health_metrics import ProjectHealthMetrics +from apps.owasp.models.project_health_requirements import ProjectHealthRequirements from apps.owasp.models.snapshot import Snapshot @@ -141,4 +143,6 @@ class SnapshotAdmin(admin.ModelAdmin): admin.site.register(Committee, CommitteeAdmin) admin.site.register(Event, EventAdmin) admin.site.register(Project, ProjectAdmin) +admin.site.register(ProjectHealthMetrics) +admin.site.register(ProjectHealthRequirements) admin.site.register(Snapshot, SnapshotAdmin) diff --git a/backend/apps/owasp/management/commands/owasp_update_project_health_requirements.py b/backend/apps/owasp/management/commands/owasp_update_project_health_requirements.py new file mode 100644 index 0000000000..97eba06269 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_update_project_health_requirements.py @@ -0,0 +1,141 @@ +"""A command to set thresholds of OWASP project health requirements.""" + +from django.core.management.base import BaseCommand + +from apps.owasp.models.project import Project +from apps.owasp.models.project_health_requirements import ProjectHealthRequirements + + +class Command(BaseCommand): + help = "Set project health requirements for each level." + + LEVEL_REQUIREMENTS = { + Project.ProjectLevel.INCUBATOR: { + "age_days": 15, + "contributors_count": 1, + "forks_count": 2, + "last_commit_days": 365, + "last_pull_request_days": 60, + "last_release_days": 365, + "open_issues_count": 10, + "open_pull_requests_count": 5, + "owasp_page_last_update_days": 60, + "recent_releases_count": 1, + "recent_releases_time_window_days": 120, + "stars_count": 10, + "total_pull_requests_count": 5, + "total_releases_count": 2, + "unanswered_issues_count": 5, + "unassigned_issues_count": 5, + }, + Project.ProjectLevel.LAB: { + "age_days": 20, + "contributors_count": 3, + "forks_count": 5, + "last_commit_days": 270, + "last_pull_request_days": 45, + "last_release_days": 365, + "open_issues_count": 8, + "open_pull_requests_count": 4, + "owasp_page_last_update_days": 45, + "recent_releases_count": 1, + "recent_releases_time_window_days": 90, + "stars_count": 25, + "total_pull_requests_count": 10, + "total_releases_count": 3, + "unanswered_issues_count": 4, + "unassigned_issues_count": 4, + }, + Project.ProjectLevel.PRODUCTION: { + "age_days": 30, + "contributors_count": 4, + "forks_count": 7, + "last_commit_days": 90, + "last_pull_request_days": 30, + "last_release_days": 180, + "open_issues_count": 5, + "open_pull_requests_count": 3, + "owasp_page_last_update_days": 30, + "recent_releases_count": 2, + "recent_releases_time_window_days": 60, + "stars_count": 40, + "total_pull_requests_count": 15, + "total_releases_count": 4, + "unanswered_issues_count": 2, + "unassigned_issues_count": 2, + }, + Project.ProjectLevel.FLAGSHIP: { + "age_days": 30, + "contributors_count": 5, + "forks_count": 10, + "last_commit_days": 180, + "last_pull_request_days": 30, + "last_release_days": 365, + "open_issues_count": 5, + "open_pull_requests_count": 3, + "owasp_page_last_update_days": 30, + "recent_releases_count": 2, + "recent_releases_time_window_days": 90, + "stars_count": 50, + "total_pull_requests_count": 20, + "total_releases_count": 5, + "unanswered_issues_count": 3, + "unassigned_issues_count": 3, + }, + } + + def add_arguments(self, parser): + parser.add_argument( + "--level", + type=str, + choices=[level[0] for level in Project.ProjectLevel.choices], + help="Project level to set requirements for", + ) + + def get_level_requirements(self, level): + """Get default requirements based on project level.""" + defaults = { + "age_days": 0, + "contributors_count": 0, + "forks_count": 0, + "last_commit_days": 0, + "last_pull_request_days": 0, + "last_release_days": 0, + "open_issues_count": 0, + "open_pull_requests_count": 0, + "owasp_page_last_update_days": 0, + "recent_releases_count": 0, + "recent_releases_time_window_days": 0, + "stars_count": 0, + "total_pull_requests_count": 0, + "total_releases_count": 0, + "unanswered_issues_count": 0, + "unassigned_issues_count": 0, + } + + return self.LEVEL_REQUIREMENTS.get(level, defaults) + + def handle(self, *args, **options): + level = options.get("level") + + if level: + defaults = self.get_level_requirements(level) + requirements, created = ProjectHealthRequirements.objects.get_or_create( + level=level, defaults=defaults + ) + + action = "Created" if created else "Updated" + print(f"{action} health requirements for {requirements.get_level_display()} projects") + else: + for level_choice in Project.ProjectLevel.choices: + level_code = level_choice[0] + defaults = self.get_level_requirements(level_code) + + requirements, created = ProjectHealthRequirements.objects.get_or_create( + level=level_code, defaults=defaults + ) + + if created: + print(f"Created default health requirements for {level_choice[1]} projects") + else: + print(f"Health requirements already exist for {level_choice[1]} projects") diff --git a/backend/apps/owasp/migrations/0016_projecthealthmetrics.py b/backend/apps/owasp/migrations/0016_projecthealthmetrics.py new file mode 100644 index 0000000000..c8dd45832c --- /dev/null +++ b/backend/apps/owasp/migrations/0016_projecthealthmetrics.py @@ -0,0 +1,124 @@ +# Generated by Django 5.1.6 on 2025-02-28 11:57 + +import django.core.validators +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0015_snapshot"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectHealthMetrics", + 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)), + ( + "contributors_count", + models.PositiveIntegerField(default=0, verbose_name="Contributors"), + ), + ( + "created_at", + models.DateTimeField(blank=True, null=True, verbose_name="Created at"), + ), + ("forks_count", models.PositiveIntegerField(default=0, verbose_name="Forks")), + ( + "is_funding_requirements_compliant", + models.BooleanField( + default=False, verbose_name="Is funding requirements compliant" + ), + ), + ( + "is_project_leaders_requirements_compliant", + models.BooleanField( + default=False, verbose_name="Is project leaders requirements compliant" + ), + ), + ( + "last_released_at", + models.DateTimeField(blank=True, null=True, verbose_name="Last released at"), + ), + ( + "last_committed_at", + models.DateTimeField(blank=True, null=True, verbose_name="Last committed at"), + ), + ( + "open_issues_count", + models.PositiveIntegerField(default=0, verbose_name="Open issues"), + ), + ( + "open_pull_requests_count", + models.PositiveIntegerField(default=0, verbose_name="Open pull requests"), + ), + ( + "owasp_page_last_updated_at", + models.DateTimeField( + blank=True, null=True, verbose_name="OWASP page last updated at" + ), + ), + ( + "pull_request_last_created_at", + models.DateTimeField( + blank=True, null=True, verbose_name="Pull request last created at" + ), + ), + ( + "recent_releases_count", + models.PositiveIntegerField(default=0, verbose_name="Recent releases"), + ), + ( + "score", + models.FloatField( + default=0.0, + help_text="Project health score (0-100)", + validators=[ + django.core.validators.MinValueValidator(0.0), + django.core.validators.MaxValueValidator(100.0), + ], + ), + ), + ("stars_count", models.PositiveIntegerField(default=0, verbose_name="Stars")), + ( + "total_issues_count", + models.PositiveIntegerField(default=0, verbose_name="Total issues"), + ), + ( + "total_pull_request_count", + models.PositiveIntegerField(default=0, verbose_name="Total pull requests"), + ), + ( + "total_releases_count", + models.PositiveIntegerField(default=0, verbose_name="Total releases"), + ), + ( + "unanswered_issues_count", + models.PositiveIntegerField(default=0, verbose_name="Unanswered issues"), + ), + ( + "unassigned_issues_count", + models.PositiveIntegerField(default=0, verbose_name="Unassigned issues"), + ), + ( + "project", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="health_metrics", + to="owasp.project", + ), + ), + ], + options={ + "verbose_name_plural": "Project Health Metrics", + "db_table": "owasp_project_health_metrics", + }, + ), + ] diff --git a/backend/apps/owasp/migrations/0017_projecthealthrequirements_and_more.py b/backend/apps/owasp/migrations/0017_projecthealthrequirements_and_more.py new file mode 100644 index 0000000000..7799843ce3 --- /dev/null +++ b/backend/apps/owasp/migrations/0017_projecthealthrequirements_and_more.py @@ -0,0 +1,103 @@ +# Generated by Django 5.1.6 on 2025-02-28 11:58 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0016_projecthealthmetrics"), + ] + + operations = [ + migrations.CreateModel( + name="ProjectHealthRequirements", + 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)), + ( + "level", + models.CharField( + choices=[ + ("other", "Other"), + ("incubator", "Incubator"), + ("lab", "Lab"), + ("production", "Production"), + ("flagship", "Flagship"), + ], + max_length=10, + unique=True, + verbose_name="Project Level", + ), + ), + ( + "age_days", + models.PositiveIntegerField(default=0, verbose_name="Project age (days)"), + ), + ( + "contributors_count", + models.PositiveIntegerField(default=0, verbose_name="Contributors"), + ), + ("forks_count", models.PositiveIntegerField(default=0, verbose_name="Forks")), + ( + "last_release_days", + models.PositiveIntegerField(default=0, verbose_name="Days since last release"), + ), + ( + "last_commit_days", + models.PositiveIntegerField(default=0, verbose_name="Days since last commit"), + ), + ( + "open_issues_count", + models.PositiveIntegerField(default=0, verbose_name="Open issues"), + ), + ( + "open_pull_requests_count", + models.PositiveIntegerField(default=0, verbose_name="Open PRs"), + ), + ( + "owasp_page_last_update_days", + models.PositiveIntegerField(default=0, verbose_name="Days since OWASP update"), + ), + ( + "last_pull_request_days", + models.PositiveIntegerField(default=0, verbose_name="Days since last PR"), + ), + ( + "recent_releases_count", + models.PositiveIntegerField(default=0, verbose_name="Recent releases"), + ), + ( + "recent_releases_time_window_days", + models.PositiveIntegerField(default=0, verbose_name="Recent releases window"), + ), + ("stars_count", models.PositiveIntegerField(default=0, verbose_name="Stars")), + ( + "total_pull_requests_count", + models.PositiveIntegerField(default=0, verbose_name="Total PRs"), + ), + ( + "total_releases_count", + models.PositiveIntegerField(default=0, verbose_name="Total releases"), + ), + ( + "unanswered_issues_count", + models.PositiveIntegerField(default=0, verbose_name="Unanswered issues"), + ), + ( + "unassigned_issues_count", + models.PositiveIntegerField(default=0, verbose_name="Unassigned issues"), + ), + ], + options={ + "verbose_name_plural": "Project Health Requirements", + "db_table": "owasp_project_health_requirements", + "ordering": ["level"], + }, + ), + ] diff --git a/backend/apps/owasp/models/project_health_metrics.py b/backend/apps/owasp/models/project_health_metrics.py new file mode 100644 index 0000000000..5b928601cf --- /dev/null +++ b/backend/apps/owasp/models/project_health_metrics.py @@ -0,0 +1,67 @@ +"""Project health metrics model.""" + +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models + +from apps.common.models import TimestampedModel + + +class ProjectHealthMetrics(TimestampedModel): + """Project health metrics model.""" + + class Meta: + db_table = "owasp_project_health_metrics" + verbose_name_plural = "Project Health Metrics" + + project = models.OneToOneField( + "owasp.Project", + on_delete=models.CASCADE, + related_name="health_metrics", + ) + + contributors_count = models.PositiveIntegerField(verbose_name="Contributors", default=0) + created_at = models.DateTimeField(verbose_name="Created at", blank=True, null=True) + forks_count = models.PositiveIntegerField(verbose_name="Forks", default=0) + is_funding_requirements_compliant = models.BooleanField( + verbose_name="Is funding requirements compliant", default=False + ) + is_project_leaders_requirements_compliant = models.BooleanField( + verbose_name="Is project leaders requirements compliant", default=False + ) + last_released_at = models.DateTimeField(verbose_name="Last released at", blank=True, null=True) + last_committed_at = models.DateTimeField( + verbose_name="Last committed at", blank=True, null=True + ) + open_issues_count = models.PositiveIntegerField(verbose_name="Open issues", default=0) + open_pull_requests_count = models.PositiveIntegerField( + verbose_name="Open pull requests", default=0 + ) + owasp_page_last_updated_at = models.DateTimeField( + verbose_name="OWASP page last updated at", blank=True, null=True + ) + pull_request_last_created_at = models.DateTimeField( + verbose_name="Pull request last created at", blank=True, null=True + ) + recent_releases_count = models.PositiveIntegerField(verbose_name="Recent releases", default=0) + # score of projects health between 0 and 100(float value) + score = models.FloatField( + default=0.0, + validators=[MinValueValidator(0.0), MaxValueValidator(100.0)], + help_text="Project health score (0-100)", + ) + stars_count = models.PositiveIntegerField(verbose_name="Stars", default=0) + total_issues_count = models.PositiveIntegerField(verbose_name="Total issues", default=0) + total_pull_request_count = models.PositiveIntegerField( + verbose_name="Total pull requests", default=0 + ) + total_releases_count = models.PositiveIntegerField(verbose_name="Total releases", default=0) + unanswered_issues_count = models.PositiveIntegerField( + verbose_name="Unanswered issues", default=0 + ) + unassigned_issues_count = models.PositiveIntegerField( + verbose_name="Unassigned issues", default=0 + ) + + def __str__(self): + """Project health metrics human readable representation.""" + return f"Health Metrics for {self.project.name}" diff --git a/backend/apps/owasp/models/project_health_requirements.py b/backend/apps/owasp/models/project_health_requirements.py new file mode 100644 index 0000000000..c12477fbc7 --- /dev/null +++ b/backend/apps/owasp/models/project_health_requirements.py @@ -0,0 +1,57 @@ +"""Project health requirements model.""" + +from django.db import models + +from apps.common.models import TimestampedModel +from apps.owasp.models.project import Project + + +class ProjectHealthRequirements(TimestampedModel): + """Project health requirements model.""" + + class Meta: + db_table = "owasp_project_health_requirements" + verbose_name_plural = "Project Health Requirements" + ordering = ["level"] + + level = models.CharField( + max_length=10, + choices=Project.ProjectLevel.choices, + unique=True, + verbose_name="Project Level", + ) + + age_days = models.PositiveIntegerField(verbose_name="Project age (days)", default=0) + contributors_count = models.PositiveIntegerField(verbose_name="Contributors", default=0) + forks_count = models.PositiveIntegerField(verbose_name="Forks", default=0) + last_release_days = models.PositiveIntegerField( + verbose_name="Days since last release", default=0 + ) + last_commit_days = models.PositiveIntegerField( + verbose_name="Days since last commit", default=0 + ) + open_issues_count = models.PositiveIntegerField(verbose_name="Open issues", default=0) + open_pull_requests_count = models.PositiveIntegerField(verbose_name="Open PRs", default=0) + owasp_page_last_update_days = models.PositiveIntegerField( + verbose_name="Days since OWASP update", default=0 + ) + last_pull_request_days = models.PositiveIntegerField( + verbose_name="Days since last PR", default=0 + ) + recent_releases_count = models.PositiveIntegerField(verbose_name="Recent releases", default=0) + recent_releases_time_window_days = models.PositiveIntegerField( + verbose_name="Recent releases window", default=0 + ) + stars_count = models.PositiveIntegerField(verbose_name="Stars", default=0) + total_pull_requests_count = models.PositiveIntegerField(verbose_name="Total PRs", default=0) + total_releases_count = models.PositiveIntegerField(verbose_name="Total releases", default=0) + unanswered_issues_count = models.PositiveIntegerField( + verbose_name="Unanswered issues", default=0 + ) + unassigned_issues_count = models.PositiveIntegerField( + verbose_name="Unassigned issues", default=0 + ) + + def __str__(self): + """Project health requirements human readable representation.""" + return f"Health Requirements for {self.get_level_display()} Projects" diff --git a/backend/tests/owasp/management/commands/owasp_update_project_health_requirements_test.py b/backend/tests/owasp/management/commands/owasp_update_project_health_requirements_test.py new file mode 100644 index 0000000000..414c325569 --- /dev/null +++ b/backend/tests/owasp/management/commands/owasp_update_project_health_requirements_test.py @@ -0,0 +1,99 @@ +from io import StringIO +from unittest.mock import MagicMock, patch + +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError + +from apps.owasp.management.commands.owasp_update_project_health_requirements import Command +from apps.owasp.models.project import Project +from apps.owasp.models.project_health_requirements import ProjectHealthRequirements + + +class TestUpdateProjectHealthRequirementsCommand: + @pytest.fixture(autouse=True) + def _setup(self): + """Set up test environment.""" + self.stdout = StringIO() + self.command = Command() + with patch( + "apps.owasp.models.project_health_requirements.ProjectHealthRequirements.objects" + ) as requirements_patch: + self.mock_requirements = requirements_patch + yield + + @pytest.mark.parametrize( + ("level", "expected_output", "display_name"), + [ + ( + "flagship", + "Created health requirements for Flagship projects", + "Flagship", + ), + ( + "incubator", + "Created health requirements for Incubator projects", + "Incubator", + ), + (None, "Created default health requirements for Flagship projects", "Flagship"), + ], + ) + def test_handle_successful_creation(self, level, expected_output, display_name): + """Test successful requirements creation.""" + mock_requirements = MagicMock(spec=ProjectHealthRequirements) + mock_requirements.get_level_display.return_value = display_name + self.mock_requirements.get_or_create.return_value = (mock_requirements, True) + + with patch("sys.stdout", new=StringIO()) as fake_out: + if level: + call_command("owasp_update_project_health_requirements", level=level) + else: + call_command("owasp_update_project_health_requirements") + + assert expected_output in fake_out.getvalue() + + def test_handle_exception(self): + """Test handling of exceptions during update.""" + error_message = "Database error" + self.mock_requirements.get_or_create.side_effect = CommandError(error_message) + + with pytest.raises(CommandError, match=error_message) as exc_info: + call_command("owasp_update_project_health_requirements") + + assert str(exc_info.value) == error_message + + @pytest.mark.parametrize( + "level", + [ + Project.ProjectLevel.FLAGSHIP, + Project.ProjectLevel.INCUBATOR, + Project.ProjectLevel.LAB, + Project.ProjectLevel.PRODUCTION, + Project.ProjectLevel.OTHER, + ], + ) + def test_get_level_requirements(self, level): + """Test default requirements generation for different project levels.""" + defaults = self.command.get_level_requirements(level) + + assert isinstance(defaults, dict) + assert "contributors_count" in defaults + assert "age_days" in defaults + assert "forks_count" in defaults + assert "last_release_days" in defaults + assert "last_commit_days" in defaults + assert "open_issues_count" in defaults + assert "open_pull_requests_count" in defaults + assert "owasp_page_last_update_days" in defaults + assert "last_pull_request_days" in defaults + assert "recent_releases_count" in defaults + assert "recent_releases_time_window_days" in defaults + assert "stars_count" in defaults + assert "total_pull_requests_count" in defaults + assert "total_releases_count" in defaults + assert "unanswered_issues_count" in defaults + assert "unassigned_issues_count" in defaults + + command_defaults = Command.LEVEL_REQUIREMENTS.get(level, {}) + for key, value in command_defaults.items(): + assert defaults[key] == value diff --git a/backend/tests/owasp/models/project_health_metrics_test.py b/backend/tests/owasp/models/project_health_metrics_test.py new file mode 100644 index 0000000000..552046f256 --- /dev/null +++ b/backend/tests/owasp/models/project_health_metrics_test.py @@ -0,0 +1,84 @@ +import pytest +from django.core.exceptions import ValidationError + +from apps.owasp.models.project import Project +from apps.owasp.models.project_health_metrics import ProjectHealthMetrics + + +class TestProjectHealthMetricsModel: + DEFAULT_SCORE = 0.0 + VALID_SCORE = 75.0 + MAX_SCORE = 100.0 + MIN_SCORE = 0.0 + + @pytest.fixture() + def mock_project(self): + """Mock a Project instance with simulated persistence.""" + project = Project(key="test_project", name="Test Project") + project.pk = 1 + return project + + @pytest.mark.parametrize( + ("project_name", "expected"), + [ + ("Secure Project", "Health Metrics for Secure Project"), + ("", "Health Metrics for "), + (None, "Health Metrics for None"), + ], + ) + def test_str_representation(self, mock_project, project_name, expected): + """Should return correct string representation.""" + mock_project.name = project_name + metrics = ProjectHealthMetrics(project=mock_project) + assert str(metrics) == expected + + @pytest.mark.parametrize( + ("score", "is_valid"), + [ + (VALID_SCORE, True), + (MAX_SCORE, True), + (MIN_SCORE, True), + (MAX_SCORE + 0.1, False), + (MIN_SCORE - 10.0, False), + (None, False), + ], + ) + def test_score_validation(self, score, is_valid): + """Should validate score within allowed range.""" + metrics = ProjectHealthMetrics(score=score) + + if is_valid: + metrics.clean_fields(exclude=["project"]) + assert metrics.score == score + else: + with pytest.raises(ValidationError) as exc_info: + metrics.clean_fields(exclude=["project"]) + assert "score" in exc_info.value.error_dict + + def test_default_score(self): + """Should initialize with default score value.""" + metrics = ProjectHealthMetrics() + assert metrics.score == self.DEFAULT_SCORE + + @pytest.mark.parametrize( + ("field_name", "expected_default"), + [ + ("contributors_count", 0), + ("forks_count", 0), + ("is_funding_requirements_compliant", False), + ("is_project_leaders_requirements_compliant", False), + ("open_issues_count", 0), + ("open_pull_requests_count", 0), + ("recent_releases_count", 0), + ("stars_count", 0), + ("total_issues_count", 0), + ("total_pull_request_count", 0), + ("total_releases_count", 0), + ("unanswered_issues_count", 0), + ("unassigned_issues_count", 0), + ], + ) + def test_count_defaults(self, field_name, expected_default): + """Should initialize count fields with proper defaults.""" + metrics = ProjectHealthMetrics() + assert getattr(metrics, field_name) == expected_default diff --git a/backend/tests/owasp/models/project_health_requirements_test.py b/backend/tests/owasp/models/project_health_requirements_test.py new file mode 100644 index 0000000000..92340ce5c0 --- /dev/null +++ b/backend/tests/owasp/models/project_health_requirements_test.py @@ -0,0 +1,60 @@ +import pytest +from django.core.exceptions import ValidationError + +from apps.owasp.models.project import Project +from apps.owasp.models.project_health_requirements import ProjectHealthRequirements + + +class TestProjectHealthRequirementsModel: + """Unit tests for ProjectHealthRequirements model validation and behavior.""" + + VALID_LEVELS = Project.ProjectLevel.values + INVALID_LEVEL = "invalid_level" + POSITIVE_INTEGER_FIELDS = [ + "contributors_count", + "age_days", + "forks_count", + "last_release_days", + "last_commit_days", + "open_issues_count", + "open_pull_requests_count", + "owasp_page_last_update_days", + "last_pull_request_days", + "recent_releases_count", + "recent_releases_time_window_days", + "stars_count", + "total_pull_requests_count", + "total_releases_count", + "unanswered_issues_count", + "unassigned_issues_count", + ] + + @pytest.mark.parametrize( + ("level", "expected"), + [ + (Project.ProjectLevel.FLAGSHIP, "Health Requirements for Flagship Projects"), + (Project.ProjectLevel.INCUBATOR, "Health Requirements for Incubator Projects"), + (Project.ProjectLevel.LAB, "Health Requirements for Lab Projects"), + (Project.ProjectLevel.OTHER, "Health Requirements for Other Projects"), + (Project.ProjectLevel.PRODUCTION, "Health Requirements for Production Projects"), + ("", "Health Requirements for Projects"), + ], + ) + def test_str_representation(self, level, expected): + assert str(ProjectHealthRequirements(level=level)) == expected + + @pytest.mark.parametrize("field", POSITIVE_INTEGER_FIELDS) + def test_positive_integer_fields_default_to_zero(self, field): + assert getattr(ProjectHealthRequirements(), field) == 0 + + @pytest.mark.parametrize("level", VALID_LEVELS) + def test_valid_level_choices(self, level): + requirements = ProjectHealthRequirements(level=level) + requirements.clean_fields(exclude=[]) + + @pytest.mark.parametrize("invalid_level", [INVALID_LEVEL, None]) + def test_invalid_level_raises_error(self, invalid_level): + requirements = ProjectHealthRequirements(level=invalid_level) + with pytest.raises(ValidationError) as exc: + requirements.clean_fields(exclude=[]) + assert "level" in exc.value.error_dict