Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Admin api ninja spike #117

Merged
merged 10 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 2 additions & 5 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve
- Add cover image for HRSA-25-019
- Add cover image for HHS-2025-ACF-ECD-TH-0106
- Add cover image for HSRA-25-071
- Add new API endpoints for importing and exporting NOFOs

### Changed

Expand All @@ -37,11 +38,7 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve
- Loading gif also used on re-import page
- bug: since adding replace_links, replace_chars was not being applied

### Migrations

- Add one new coach ("Sara") and 1 new HRSA designer ("KieuMy")

## [1.39.0] - 2023-12-04
## [1.40.0] - 2023-12-04

### Added

Expand Down
1 change: 1 addition & 0 deletions bloom_nofos/bloom_nofos/.env.example
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
SECRET_KEY="123-fake-key"
DEBUG=1
API_TOKEN=""
# Empty string for DATABASE_URL means it will create and use a local sqlite db
DATABASE_URL=""
DJANGO_ALLOWED_HOSTS=""
Expand Down
3 changes: 3 additions & 0 deletions bloom_nofos/bloom_nofos/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@

SECRET_KEY = env("SECRET_KEY", default="bad-secret-key-please-change")

API_TOKEN = env("API_TOKEN", default=None)

ALLOWED_HOSTS = [
"0.0.0.0",
"127.0.0.1",
Expand Down Expand Up @@ -118,6 +120,7 @@
"easyaudit",
"djversion",
"django_mirror",
"ninja",
]

MIDDLEWARE = [
Expand Down
3 changes: 2 additions & 1 deletion bloom_nofos/bloom_nofos/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
from django.http import HttpResponse
from django.urls import include, path, re_path
from django.views.generic.base import RedirectView

from nofos.api.api import api
from . import views

handler404 = views.page_not_found
Expand Down Expand Up @@ -47,4 +47,5 @@
path("test-mode", views.TestModeView.as_view(), name="test_mode"),
path("", views.index, name="index"),
path("404/", views.page_not_found),
path("api/", api.urls),
]
2 changes: 0 additions & 2 deletions bloom_nofos/nofos/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,6 @@ class SectionAdmin(admin.ModelAdmin):
inlines = [SubsectionLinkInline]
model = Section
list_display = ["id", "nofo_number", "name"]
change_form_template = "admin/section_change_form.html"

@admin.display(ordering="nofo__number")
def nofo_number(self, obj):
Expand All @@ -101,7 +100,6 @@ def get_urls(self):


class NofoAdmin(MirrorAdmin, admin.ModelAdmin):
change_form_template = "admin/nofo_change_form.html"
form = NofoModelForm
inlines = [SectionLinkInline]
actions = ["duplicate_nofo"]
Expand Down
1 change: 1 addition & 0 deletions bloom_nofos/nofos/api/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .api import api
62 changes: 62 additions & 0 deletions bloom_nofos/nofos/api/api.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from ninja import NinjaAPI, Schema
from ninja.security import HttpBearer
from django.core.exceptions import ValidationError
from .schemas import NofoSchema, ErrorSchema, SuccessSchema
from nofos.models import Nofo
from nofos.views import _build_nofo
from django.conf import settings


class BearerAuth(HttpBearer):
def authenticate(self, request, token):
if token and settings.API_TOKEN and token == settings.API_TOKEN:
return token
return None


api = NinjaAPI(
auth=BearerAuth(),
urls_namespace="api",
docs_url="/docs",
)


@api.post("/nofos", response={201: SuccessSchema, 400: ErrorSchema})
def create_nofo(request, payload: NofoSchema):
try:
data = payload.dict()
sections = data.pop("sections", [])

# Remove fields we dont want set on import
excluded_fields = ["id", "archived", "status", "group"]
for field in excluded_fields:
data.pop(field, None)

# Create NOFO
nofo = Nofo(**data)
pcraig3 marked this conversation as resolved.
Show resolved Hide resolved
nofo.group = "bloom"
Copy link
Collaborator

@pcraig3 pcraig3 Dec 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine for now.

If we ever get to the point where people are importing NOFOs other than us, we will need to change this from being hardcoded.

nofo.full_clean()
nofo.save()

_build_nofo(nofo, sections)
nofo.save()

serialized_nofo = NofoSchema.from_orm(nofo)
return_response = api.create_response(request, serialized_nofo, status=201)
return_response.headers["Location"] = f"/api/nofos/{nofo.id}"
return return_response

except ValidationError as e:
return 400, {"message": "Model validation error", "details": e.message_dict}
except Exception as e:
return 400, {"message": str(e)}


@api.get("/nofos/{nofo_id}", response={200: NofoSchema, 404: ErrorSchema})
def get_nofo(request, nofo_id: int):
"""Export a NOFO by ID"""
try:
nofo = Nofo.objects.get(id=nofo_id, archived__isnull=True)
return 200, nofo
except Nofo.DoesNotExist:
return 404, {"message": "NOFO not found"}
79 changes: 79 additions & 0 deletions bloom_nofos/nofos/api/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
from ninja import ModelSchema, Schema
from typing import List, Optional
from nofos.models import Nofo, Section, Subsection


class SubsectionSchema(ModelSchema):
class Config:
model = Subsection
model_fields = [
"name",
"html_id",
"order",
"tag",
"body",
"callout_box",
"html_class",
]
model_fields_optional = ["html_class"] # Fields that should have defaults


class SectionBaseSchema(ModelSchema):
class Config:
model = Section
model_fields = ["name", "html_id", "order", "has_section_page"]


class SectionSchema(SectionBaseSchema):
subsections: List[SubsectionSchema]


class NofoBaseSchema(ModelSchema):
class Config:
model = Nofo
model_fields = [
"id",
"title",
"filename",
pcraig3 marked this conversation as resolved.
Show resolved Hide resolved
"short_name",
"number",
"opdiv",
"agency",
"tagline",
"application_deadline",
"subagency",
"subagency2",
"author",
"subject",
"keywords",
"theme",
"cover",
"icon_style",
"status",
"cover_image",
"cover_image_alt_text",
"inline_css",
]
model_fields_optional = [
"subagency",
"subagency2",
"author",
"subject",
"keywords",
"cover_image",
"cover_image_alt_text",
"inline_css",
]


class NofoSchema(NofoBaseSchema):
sections: List[SectionSchema]

Comment on lines +69 to +71
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this solves the ordering problem! How does this work (and how did you figure it out)?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Completely by accident. Glad it worked though


class ErrorSchema(Schema):
message: str
details: Optional[dict] = None


class SuccessSchema(Schema):
nofo: NofoSchema
141 changes: 141 additions & 0 deletions bloom_nofos/nofos/api/tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
from django.test import TestCase, override_settings
from django.conf import settings
from nofos.models import Nofo, Section, Subsection
import json
import os


@override_settings(API_TOKEN="test-token-for-ci")
class NofoAPITest(TestCase):
def setUp(self):
self.valid_token = "test-token-for-ci"
self.headers = {
"HTTP_AUTHORIZATION": f"Bearer {self.valid_token}",
}

# Create test NOFO for export tests
self.nofo = Nofo.objects.create(
title="API Test NOFO",
number="00000",
tagline="Test me via API!",
theme="landscape-cdc-blue",
group="bloom",
)
self.section = Section.objects.create(
nofo=self.nofo, name="API Test NOFO: Section 1", order=1
)
self.subsection = Subsection.objects.create(
section=self.section,
name="API Test NOFO: Subsection 1",
order=1,
tag="h3",
)

# Load fixture data for import tests
fixture_path = os.path.join(
settings.BASE_DIR, "nofos", "fixtures", "json", "cms-u2u-25-001.json"
)
Comment on lines +34 to +37
Copy link
Collaborator

@pcraig3 pcraig3 Dec 17, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great!

There are a few other fixtures in there I used for making sure bad data was overwritten (hrsa- and published-), but I think we can include those checks in the import_nofo test (see comment above) and then just remove these other fixtures.

with open(fixture_path, "r") as f:
self.fixture_data = json.load(f)

def test_export_nofo(self):
"""Test exporting a NOFO via API"""
response = self.client.get(f"/api/nofos/{self.nofo.id}", **self.headers)

self.assertEqual(response.status_code, 200)
data = response.json()
self.assertEqual(data["id"], self.nofo.id)
self.assertEqual(data["title"], self.nofo.title)
self.assertEqual(data["sections"][0]["name"], self.section.name)
self.assertEqual(
data["sections"][0]["subsections"][0]["name"], self.subsection.name
)

def test_export_archived_nofo_returns_404(self):
"""Test that archived NOFOs return 404"""
self.nofo.archived = "2024-01-01"
self.nofo.save()

response = self.client.get(f"/api/nofos/{self.nofo.id}", **self.headers)

self.assertEqual(response.status_code, 404)

def test_export_nonexistent_nofo(self):
"""Test exporting a non-existent NOFO"""
response = self.client.get("/api/nofos/99999", **self.headers)

self.assertEqual(response.status_code, 404)

def test_unauthorized_export(self):
"""Test exporting without authorization"""
response = self.client.get(f"/api/nofos/{self.nofo.id}")
self.assertEqual(response.status_code, 401)

def test_import_nofo(self):
"""Test importing a valid NOFO using fixture data"""

import_data = self.fixture_data.copy()
import_data["id"] = 99999
import_data["status"] = "published"
import_data["group"] = "different-group"
import_data["archived"] = "2024-01-01"

response = self.client.post(
"/api/nofos",
data=json.dumps(import_data),
content_type="application/json",
**self.headers,
)

self.assertEqual(response.status_code, 201)

# Verify NOFO was created with correct data
nofo = Nofo.objects.get(number="CMS-2U2-25-001")

self.assertNotEqual(nofo.id, 99999)
self.assertEqual(nofo.status, "draft")
self.assertEqual(nofo.group, "bloom")
self.assertIsNone(nofo.archived)

# Verify sections and subsections
self.assertEqual(len(nofo.sections.all()), len(self.fixture_data["sections"]))
first_section = nofo.sections.first()
self.assertEqual(first_section.name, "Step 1: Review the Opportunity")
self.assertEqual(first_section.subsections.first().name, "Basic information")

def test_import_nofo_without_sections(self):
"""Test importing a NOFO without sections"""
payload = {"title": "No Sections NOFO", "number": "TEST-002", "sections": []}

response = self.client.post(
"/api/nofos",
data=json.dumps(payload),
content_type="application/json",
**self.headers,
)

self.assertEqual(response.status_code, 400)

def test_import_nofo_with_id(self):
"""Test that providing an ID is ignored during import"""
# Use fixture data but modify the ID
import_data = self.fixture_data.copy()
import_data["id"] = 999

# Remove fields we don't want
excluded_fields = ["archived", "status", "group"]
for field in excluded_fields:
import_data.pop(field, None)

response = self.client.post(
"/api/nofos",
data=json.dumps(import_data),
content_type="application/json",
**self.headers,
)

self.assertEqual(response.status_code, 201)

# Verify NOFO was created with a different ID
nofo = Nofo.objects.get(number="CMS-2U2-25-001")
self.assertNotEqual(nofo.id, 999)
Loading
Loading