Skip to content

Commit fc7ac99

Browse files
authored
Created Project_Stack_Element_Xref Model | API and test cases
1 parent 4fc3f7d commit fc7ac99

File tree

10 files changed

+280
-6
lines changed

10 files changed

+280
-6
lines changed

app/core/admin.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from .models import PracticeArea
1919
from .models import ProgramArea
2020
from .models import Project
21+
from .models import ProjectStackElementXref
2122
from .models import ProjectStatus
2223
from .models import ProjectUrl
2324
from .models import Referrer
@@ -311,3 +312,11 @@ class ProjectUrlAdmin(admin.ModelAdmin):
311312
"external_id",
312313
"url",
313314
)
315+
316+
317+
@admin.register(ProjectStackElementXref)
318+
class ProjectStackElementXrefAdmin(admin.ModelAdmin):
319+
list_display = (
320+
"project",
321+
"stack_element",
322+
)

app/core/api/serializers.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from core.models import PracticeArea
1515
from core.models import ProgramArea
1616
from core.models import Project
17+
from core.models import ProjectStackElementXref
1718
from core.models import ProjectStatus
1819
from core.models import ProjectUrl
1920
from core.models import Referrer
@@ -495,3 +496,23 @@ class Meta:
495496
"url",
496497
)
497498
read_only_fields = ("uuid", "created_at", "updated_at")
499+
500+
501+
class ProjectStackElementXrefSerializer(serializers.ModelSerializer):
502+
project_name = serializers.CharField(source="project.name", read_only=True)
503+
stack_element_name = serializers.CharField(
504+
source="stack_element.name", read_only=True
505+
)
506+
507+
class Meta:
508+
model = ProjectStackElementXref
509+
fields = (
510+
"uuid",
511+
"project",
512+
"project_name",
513+
"stack_element",
514+
"stack_element_name",
515+
"created_at",
516+
"updated_at",
517+
)
518+
read_only_fields = ("uuid", "created_at", "updated_at")

app/core/api/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .views import PermissionTypeViewSet
1414
from .views import PracticeAreaViewSet
1515
from .views import ProgramAreaViewSet
16+
from .views import ProjectStackElementXrefViewSet
1617
from .views import ProjectStatusViewSet
1718
from .views import ProjectUrlViewSet
1819
from .views import ProjectViewSet
@@ -66,6 +67,11 @@
6667
router.register(
6768
r"user-status-types", UserStatusTypeViewSet, basename="user-status-type"
6869
)
70+
router.register(
71+
r"project-stack-elements",
72+
ProjectStackElementXrefViewSet,
73+
basename="project-stack-element",
74+
)
6975
urlpatterns = [
7076
path("me/", UserProfileAPIView.as_view(), name="my_profile"),
7177
]

app/core/api/views.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from ..models import PracticeArea
2525
from ..models import ProgramArea
2626
from ..models import Project
27+
from ..models import ProjectStackElementXref
2728
from ..models import ProjectStatus
2829
from ..models import ProjectUrl
2930
from ..models import Referrer
@@ -50,6 +51,7 @@
5051
from .serializers import PracticeAreaSerializer
5152
from .serializers import ProgramAreaSerializer
5253
from .serializers import ProjectSerializer
54+
from .serializers import ProjectStackElementXrefSerializer
5355
from .serializers import ProjectStatusSerializer
5456
from .serializers import ProjectUrlSerializer
5557
from .serializers import ReferrerSerializer
@@ -518,3 +520,9 @@ class ProjectUrlViewSet(viewsets.ModelViewSet):
518520
permission_classes = [IsAuthenticated]
519521
queryset = ProjectUrl.objects.all()
520522
serializer_class = ProjectUrlSerializer
523+
524+
525+
class ProjectStackElementXrefViewSet(viewsets.ModelViewSet):
526+
permission_classes = [IsAuthenticated]
527+
queryset = ProjectStackElementXref.objects.all()
528+
serializer_class = ProjectStackElementXrefSerializer
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Generated by Django 4.2.16 on 2025-10-21 04:16
2+
3+
from django.db import migrations, models
4+
import django.db.models.deletion
5+
import uuid
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
dependencies = [
11+
('core', '0040_projecturl'),
12+
]
13+
14+
operations = [
15+
migrations.CreateModel(
16+
name='ProjectStackElementXref',
17+
fields=[
18+
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False, unique=True)),
19+
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Created at')),
20+
('updated_at', models.DateTimeField(auto_now=True, verbose_name='Updated at')),
21+
('project', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='project_stack_elements', to='core.project')),
22+
('stack_element', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='stack_element_projects', to='core.stackelement')),
23+
],
24+
options={
25+
'db_table': 'project_stack_element_xref',
26+
},
27+
),
28+
migrations.AddField(
29+
model_name='project',
30+
name='stack_elements',
31+
field=models.ManyToManyField(blank=True, related_name='projects', through='core.ProjectStackElementXref', to='core.stackelement'),
32+
),
33+
migrations.AddConstraint(
34+
model_name='projectstackelementxref',
35+
constraint=models.UniqueConstraint(fields=('project', 'stack_element'), name='unique_project_stack_element'),
36+
),
37+
]
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0040_projecturl
1+
0041_projectstackelementxref_project_stack_elements_and_more

app/core/models.py

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,12 @@ class Project(AbstractBaseModel):
198198
blank=True,
199199
through="ProjectProgramAreaXref",
200200
)
201+
stack_elements = models.ManyToManyField(
202+
"StackElement",
203+
through="ProjectStackElementXref",
204+
related_name="projects",
205+
blank=True,
206+
)
201207

202208
def __str__(self):
203209
return f"{self.name}"
@@ -386,8 +392,6 @@ class StackElementType(AbstractBaseModel):
386392
name = models.CharField(max_length=255)
387393
description = models.TextField(blank=True)
388394

389-
# PK of this model is the ForeignKey for stack_element
390-
391395
def __str__(self):
392396
return f"{self.name}"
393397

@@ -404,9 +408,6 @@ class StackElement(AbstractBaseModel):
404408
active = models.BooleanField(null=True)
405409
element_type = models.ForeignKey(StackElementType, on_delete=models.CASCADE)
406410

407-
# PK of this model is the ForeignKey for project_stack_element_xref
408-
# we might be able to use the builtin django many-to-many relation that manages the xref table automatically
409-
410411
class Meta:
411412
verbose_name_plural = "Stack Elements"
412413

@@ -569,3 +570,30 @@ class ProjectUrl(AbstractBaseModel):
569570

570571
def __str__(self):
571572
return f"{self.name}"
573+
574+
575+
class ProjectStackElementXref(AbstractBaseModel):
576+
"""
577+
Cross-reference table joining a project to a stack element.
578+
This allows a project to be associated with multiple stack elements and vice versa.
579+
"""
580+
581+
project = models.ForeignKey(
582+
Project, on_delete=models.CASCADE, related_name="project_stack_elements"
583+
)
584+
stack_element = models.ForeignKey(
585+
StackElement, on_delete=models.CASCADE, related_name="stack_element_projects"
586+
)
587+
588+
class Meta:
589+
db_table = "project_stack_element_xref"
590+
constraints = [
591+
models.UniqueConstraint(
592+
fields=["project", "stack_element"], name="unique_project_stack_element"
593+
)
594+
]
595+
596+
def __str__(self):
597+
return (
598+
f"Project: {self.project.name} -> StackElement: {self.stack_element.name}"
599+
)

app/core/tests/conftest.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from ..models import PracticeArea
1818
from ..models import ProgramArea
1919
from ..models import Project
20+
from ..models import ProjectStackElementXref
2021
from ..models import ProjectStatus
2122
from ..models import ProjectUrl
2223
from ..models import Referrer
@@ -381,3 +382,14 @@ def project_url(project, url_type):
381382
external_id="This is a test external id",
382383
url="https://test.com",
383384
)
385+
386+
387+
@pytest.fixture
388+
def project_stack_element_xref(project, stack_element):
389+
"""
390+
Fixture to create and return a ProjectStackElementXref record
391+
linking a project and a stack element.
392+
"""
393+
return ProjectStackElementXref.objects.create(
394+
project=project, stack_element=stack_element
395+
)

app/core/tests/test_api.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
1+
from uuid import UUID
2+
13
import pytest
24
from django.urls import reverse
35
from rest_framework import status
46

57
from core.api.serializers import ProgramAreaSerializer
68
from core.api.serializers import UserSerializer
79
from core.models import ProgramArea
10+
from core.models import ProjectStackElementXref
811
from core.models import UserPermission
912

1013
pytestmark = pytest.mark.django_db
@@ -38,6 +41,7 @@
3841
SOC_MAJOR_URL = reverse("soc-major-list")
3942
SOC_MINORS_URL = reverse("soc-minor-list")
4043
URL_TYPE_URL = reverse("url-type-list")
44+
PROJECT_STACK_ELEMENTS_URL = reverse("project-stack-element-list")
4145

4246
CREATE_USER_PAYLOAD = {
4347
"username": "TestUserAPI",
@@ -608,3 +612,113 @@ def test_project_url_url_type_relationship(auth_client, url_type, project_url):
608612

609613
# Verify the url_type relationship was set correctly
610614
assert res.data["url_type"] == url_type.pk
615+
616+
617+
def test_create_project_stack_element(auth_client, project, stack_element):
618+
payload = {
619+
"project": str(project.uuid),
620+
"stack_element": str(stack_element.uuid),
621+
}
622+
res = auth_client.post(PROJECT_STACK_ELEMENTS_URL, payload)
623+
assert res.status_code == status.HTTP_201_CREATED
624+
625+
assert UUID(str(res.data["project"])) == project.uuid
626+
assert UUID(str(res.data["stack_element"])) == stack_element.uuid
627+
628+
629+
def test_list_project_stack_elements(auth_client, project_stack_element_xref):
630+
res = auth_client.get(PROJECT_STACK_ELEMENTS_URL)
631+
assert res.status_code == status.HTTP_200_OK
632+
633+
# One record created via fixture
634+
assert len(res.data) == 1
635+
assert UUID(str(res.data[0]["project"])) == project_stack_element_xref.project.uuid
636+
assert (
637+
UUID(str(res.data[0]["stack_element"]))
638+
== project_stack_element_xref.stack_element.uuid
639+
)
640+
641+
642+
def test_retrieve_project_stack_element(auth_client, project_stack_element_xref):
643+
url = reverse(
644+
"project-stack-element-detail", args=[project_stack_element_xref.uuid]
645+
)
646+
res = auth_client.get(url)
647+
648+
assert res.status_code == status.HTTP_200_OK
649+
assert UUID(str(res.data["uuid"])) == project_stack_element_xref.uuid
650+
assert UUID(str(res.data["project"])) == project_stack_element_xref.project.uuid
651+
assert (
652+
UUID(str(res.data["stack_element"]))
653+
== project_stack_element_xref.stack_element.uuid
654+
)
655+
656+
657+
def test_delete_project_stack_element(auth_client, project_stack_element_xref):
658+
url = reverse(
659+
"project-stack-element-detail", args=[project_stack_element_xref.uuid]
660+
)
661+
res = auth_client.delete(url)
662+
663+
assert res.status_code == status.HTTP_204_NO_CONTENT
664+
assert not ProjectStackElementXref.objects.filter(
665+
uuid=project_stack_element_xref.uuid
666+
).exists()
667+
668+
669+
def test_prevent_duplicate_project_stack_element(auth_client, project, stack_element):
670+
payload = {"project": str(project.uuid), "stack_element": str(stack_element.uuid)}
671+
672+
# First creation works
673+
res1 = auth_client.post(PROJECT_STACK_ELEMENTS_URL, payload)
674+
assert res1.status_code == status.HTTP_201_CREATED
675+
676+
# Second creation should fail due to unique constraint
677+
res2 = auth_client.post(PROJECT_STACK_ELEMENTS_URL, payload)
678+
assert res2.status_code == status.HTTP_400_BAD_REQUEST
679+
680+
# Assert error mentions uniqueness
681+
assert any("unique" in str(err).lower() for err in res2.data.values())
682+
683+
684+
def test_project_stack_element_workflow(auth_client):
685+
# Create a StackElementType
686+
stack_type_payload = {"name": "Language", "description": "Programming language"}
687+
res_type = auth_client.post(reverse("stack-element-type-list"), stack_type_payload)
688+
assert res_type.status_code == status.HTTP_201_CREATED
689+
stack_type_uuid = UUID(res_type.data["uuid"])
690+
691+
# Create a StackElement "Python"
692+
stack_element_payload = {
693+
"name": "Python",
694+
"description": "A high-level programming language",
695+
"url": "https://www.python.org/",
696+
"logo": "https://upload.wikimedia.org/wikipedia/commons/c/c3/Python-logo-notext.svg",
697+
"active": True,
698+
"element_type": stack_type_uuid,
699+
}
700+
res_element = auth_client.post(reverse("stack-element-list"), stack_element_payload)
701+
assert res_element.status_code == status.HTTP_201_CREATED
702+
stack_element_uuid = UUID(res_element.data["uuid"])
703+
704+
# Create a Project "PeopleDepot"
705+
project_payload = {
706+
"name": "PeopleDepot",
707+
"description": "People management system",
708+
"hide": False,
709+
}
710+
res_project = auth_client.post(reverse("project-list"), project_payload)
711+
assert res_project.status_code == status.HTTP_201_CREATED
712+
project_uuid = UUID(res_project.data["uuid"])
713+
714+
# Link Project + StackElement
715+
link_payload = {"project": project_uuid, "stack_element": stack_element_uuid}
716+
res_link = auth_client.post(reverse("project-stack-element-list"), link_payload)
717+
assert res_link.status_code == status.HTTP_201_CREATED
718+
719+
# Verify link shows up
720+
res_list = auth_client.get(reverse("project-stack-element-list"))
721+
assert res_list.status_code == status.HTTP_200_OK
722+
assert len(res_list.data) == 1
723+
assert res_list.data[0]["project"] == project_uuid
724+
assert res_list.data[0]["stack_element"] == stack_element_uuid

0 commit comments

Comments
 (0)