diff --git a/.gitignore b/.gitignore index b5ab3d3f..88d633ef 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ docs/build *.pyc .vscode +TODO.md diff --git a/app/core/admin.py b/app/core/admin.py index e3aaebac..a6cfcef3 100644 --- a/app/core/admin.py +++ b/app/core/admin.py @@ -6,6 +6,7 @@ from django.contrib.auth.forms import UsernameField from django.utils.translation import gettext_lazy as _ +from .models import Accomplishment from .models import Affiliate from .models import Affiliation from .models import CheckType @@ -332,6 +333,17 @@ class UrlStatusTypeAdmin(admin.ModelAdmin): list_display = ("name", "description") +@admin.register(Accomplishment) +class AccomplishmentAdmin(admin.ModelAdmin): + list_display = ( + "title", + "project", + "accomplished_on", + "created_at", + ) + list_filter = ("project", "accomplished_on") + + @admin.register(Organization) class OrganizationAdmin(admin.ModelAdmin): list_display = ("name", "time_zone") diff --git a/app/core/api/serializers.py b/app/core/api/serializers.py index 2306b98a..299bb69c 100644 --- a/app/core/api/serializers.py +++ b/app/core/api/serializers.py @@ -1,6 +1,7 @@ from rest_framework import serializers from timezone_field.rest_framework import TimeZoneSerializerField +from core.models import Accomplishment from core.models import Affiliate from core.models import Affiliation from core.models import CheckType @@ -445,6 +446,28 @@ class Meta: read_only_fields = ("uuid", "created_at", "updated_at") +class AccomplishmentSerializer(serializers.ModelSerializer): + """Used to retrieve accomplishment info""" + + class Meta: + model = Accomplishment + fields = ( + "uuid", + "created_at", + "updated_at", + "project", + "title", + "description", + "url", + "accomplished_on", + ) + read_only_fields = ( + "uuid", + "created_at", + "updated_at", + ) + + class UrlTypeSerializer(serializers.ModelSerializer): """Used to retrieve url_type info""" diff --git a/app/core/api/urls.py b/app/core/api/urls.py index 61d9b809..db8c74fc 100644 --- a/app/core/api/urls.py +++ b/app/core/api/urls.py @@ -1,6 +1,7 @@ from django.urls import path from rest_framework import routers +from .views import AccomplishmentViewSet from .views import AffiliateViewSet from .views import AffiliationViewSet from .views import CheckTypeViewSet @@ -67,6 +68,7 @@ router.register(r"project-urls", ProjectUrlViewSet, basename="project-url") router.register(r"soc-majors", SocMajorViewSet, basename="soc-major") router.register(r"soc-minors", SocMinorViewSet, basename="soc-minor") +router.register(r"accomplishments", AccomplishmentViewSet, basename="accomplishment") router.register(r"url-types", UrlTypeViewSet, basename="url-type") router.register( r"user-status-types", UserStatusTypeViewSet, basename="user-status-type" diff --git a/app/core/api/views.py b/app/core/api/views.py index a30f1270..6b412f03 100644 --- a/app/core/api/views.py +++ b/app/core/api/views.py @@ -16,6 +16,7 @@ from core.models import WinType +from ..models import Accomplishment from ..models import Affiliate from ..models import Affiliation from ..models import CheckType @@ -46,6 +47,7 @@ from ..models import UserCheck from ..models import UserPermission from ..models import UserStatusType +from .serializers import AccomplishmentSerializer from .serializers import AffiliateSerializer from .serializers import AffiliationSerializer from .serializers import CheckTypeSerializer @@ -398,6 +400,20 @@ class AffiliationViewSet(viewsets.ModelViewSet): serializer_class = AffiliationSerializer +@extend_schema_view( + list=extend_schema(description="Return a list of all accomplishments"), + create=extend_schema(description="Create a new accomplishment"), + retrieve=extend_schema(description="Return the details of an accomplishment"), + destroy=extend_schema(description="Delete an accomplishment"), + update=extend_schema(description="Update an accomplishment"), + partial_update=extend_schema(description="Patch an accomplishment"), +) +class AccomplishmentViewSet(viewsets.ModelViewSet): + permission_classes = [IsAuthenticated] + queryset = Accomplishment.objects.all() + serializer_class = AccomplishmentSerializer + + @extend_schema_view( list=extend_schema(description="Return a list of all the check_type"), create=extend_schema(description="Create a new check_type"), diff --git a/app/core/migrations/0046_accomplishment_and_more.py b/app/core/migrations/0046_accomplishment_and_more.py new file mode 100644 index 00000000..c3d79f22 --- /dev/null +++ b/app/core/migrations/0046_accomplishment_and_more.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.16 on 2026-01-16 06:58 + +from django.db import migrations, models +import django.db.models.deletion +import uuid + + +class Migration(migrations.Migration): + + dependencies = [('core', '0045_wintype')] + + operations = [ + migrations.CreateModel( + name='Accomplishment', + fields=[ + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')), + ('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')), + ('title', models.CharField(db_comment='Title of the accomplishment', help_text='Title of the accomplishment', max_length=255)), + ('description', models.TextField(db_comment='Detailed description of the accomplishment', help_text='Detailed description of the accomplishment')), + ('url', models.URLField(db_comment='URL link to the accomplishment', help_text='URL link to the accomplishment')), + ('accomplished_on', models.DateTimeField(db_comment='Date when the accomplishment was achieved', help_text='Date when the accomplishment was achieved')), + ('project', models.ForeignKey(db_comment='Project this accomplishment belongs to', help_text='Project this accomplishment belongs to', on_delete=django.db.models.deletion.PROTECT, related_name='accomplishments', to='core.project')), + ], + options={ + 'indexes': [models.Index(fields=['project'], name='core_accomp_project_idx'), models.Index(fields=['accomplished_on'], name='core_accomp_date_idx')], + }, + ), + migrations.AddConstraint( + model_name='accomplishment', + constraint=models.UniqueConstraint(fields=('project', 'title'), name='unique_accomplishment_per_project'), + ), + ] diff --git a/app/core/migrations/max_migration.txt b/app/core/migrations/max_migration.txt index a16d3b12..f003c426 100644 --- a/app/core/migrations/max_migration.txt +++ b/app/core/migrations/max_migration.txt @@ -1 +1 @@ -0045_wintype +0046_accomplishment_and_more diff --git a/app/core/models.py b/app/core/models.py index 9cb1f89b..927b6f31 100644 --- a/app/core/models.py +++ b/app/core/models.py @@ -12,7 +12,7 @@ class AbstractBaseModel(models.Model): """ - Base abstract model, that has `uuid` instead of `id` and included `created_at`, `updated_at` fields. + Base abstract model, that has `uuid` instead of `id` fields. """ uuid = models.UUIDField( @@ -507,6 +507,52 @@ def __str__(self): return self.title +class Accomplishment(AbstractBaseModel): + """ + Project accomplishments and milestones + """ + + project = models.ForeignKey( + Project, + on_delete=models.PROTECT, + related_name="accomplishments", + db_comment="Project this accomplishment belongs to", + help_text="Project this accomplishment belongs to", + ) + title = models.CharField( + max_length=255, + db_comment="Title of the accomplishment", + help_text="Title of the accomplishment", + ) + description = models.TextField( + db_comment="Detailed description of the accomplishment", + help_text="Detailed description of the accomplishment", + ) + url = models.URLField( + db_comment="URL link to the accomplishment", + help_text="URL link to the accomplishment", + ) + accomplished_on = models.DateTimeField( + db_comment="Date when the accomplishment was achieved", + help_text="Date when the accomplishment was achieved", + ) + + class Meta: + indexes = [ + models.Index(fields=["project"], name="core_accomp_project_idx"), + models.Index(fields=["accomplished_on"], name="core_accomp_date_idx"), + ] + constraints = [ + models.UniqueConstraint( + fields=["project", "title"], + name="unique_accomplishment_per_project", + ) + ] + + def __str__(self): + return self.title + + class ProjectProgramAreaXref(AbstractBaseModel): project = models.ForeignKey(Project, on_delete=models.CASCADE) program_area = models.ForeignKey(ProgramArea, on_delete=models.CASCADE) diff --git a/app/core/tests/conftest.py b/app/core/tests/conftest.py index 4b6d8513..fd72248b 100644 --- a/app/core/tests/conftest.py +++ b/app/core/tests/conftest.py @@ -4,6 +4,7 @@ from constants import admin_project from constants import practice_lead_project +from ..models import Accomplishment from ..models import Affiliate from ..models import Affiliation from ..models import CheckType @@ -406,6 +407,17 @@ def url_status_type(db): ) +@pytest.fixture +def accomplishment(project): + return Accomplishment.objects.create( + project=project, + title="Test Accomplishment", + description="This is a test accomplishment", + url="https://example.com", + accomplished_on="2025-09-11T18:05:00Z", + ) + + @pytest.fixture def organization(): return Organization.objects.create( diff --git a/app/core/tests/test_api.py b/app/core/tests/test_api.py index ad579d0a..643f8acf 100644 --- a/app/core/tests/test_api.py +++ b/app/core/tests/test_api.py @@ -45,6 +45,7 @@ PROJECT_URLS_URL = reverse("project-url-list") SOC_MAJOR_URL = reverse("soc-major-list") SOC_MINORS_URL = reverse("soc-minor-list") +ACCOMPLISHMENT_URL = reverse("accomplishment-list") URL_TYPE_URL = reverse("url-type-list") PROJECT_STACK_ELEMENTS_URL = reverse("project-stack-element-list") URL_STATUS_TYPES_URL = reverse("url-status-type-list") @@ -480,6 +481,21 @@ def test_soc_minor_soc_major_relationship(auth_client, soc_minor, soc_major): assert soc_major_exists is True +def test_accomplishment(auth_client, project): + """Test that we can create a accomplishment""" + + payload = { + "project": project.uuid, + "title": "Test title", + "description": "Test description", + "url": "https://redwind01.com", + "accomplished_on": "2024-01-01T18:00:00Z", + } + res = auth_client.post(ACCOMPLISHMENT_URL, payload) + assert res.status_code == status.HTTP_201_CREATED + assert res.data["title"] == payload["title"] + + def test_project_sdg_xref(auth_client, project, sdg, sdg1): def get_object(objects, target_uuid): for obj in objects: