diff --git a/.circleci/config.yml b/.circleci/config.yml index 0d020261fe..31f3195a04 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -94,7 +94,7 @@ jobs: - run: name: Create/run migrations command: | - python manage.py migrate + python manage.py migrate --no-input --traceback --verbosity 3 working_directory: ~/project/django-backend/ # Only use SonarCloud security checking for now. diff --git a/.safety.dependency.ignore b/.safety.dependency.ignore index 77327045af..f74cdfaaf3 100644 --- a/.safety.dependency.ignore +++ b/.safety.dependency.ignore @@ -12,6 +12,6 @@ 44717 2022-03-01 # numpy - dependencies not caught up yet 44716 2022-03-01 # numpy - dependencies not caught up yet 43975 2022-03-01 # urllib3 - botocore dependency needs to catch up -48040 2022-09-01 # django -48041 2022-09-01 # django -48542 2022-09-01 # pyjwt +48040 2022-10-01 # django +48041 2022-10-01 # django +48542 2022-10-01 # pyjwt diff --git a/bin/run-api.sh b/bin/run-api.sh index 38cbb0e9e9..778a063b28 100755 --- a/bin/run-api.sh +++ b/bin/run-api.sh @@ -1,7 +1,6 @@ cd django-backend -# Run migrations -./manage.py migrate --noinput > migrate.out - -# Run application -python wait_for_db.py && gunicorn --bind 0.0.0.0:8080 fecfiler.wsgi -w 9 -t 200 +# Run migrations and application +./manage.py migrate --no-input --traceback --verbosity 3 > migrate.out && + python wait_for_db.py && + gunicorn --bind 0.0.0.0:8080 fecfiler.wsgi -w 9 -t 200 diff --git a/django-backend/fecfiler/authentication/__init__.py b/django-backend/fecfiler/authentication/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/django-backend/fecfiler/authentication/test_token.py b/django-backend/fecfiler/authentication/test_token.py new file mode 100644 index 0000000000..c99e7dc972 --- /dev/null +++ b/django-backend/fecfiler/authentication/test_token.py @@ -0,0 +1,36 @@ +import unittest +from unittest.mock import Mock + + +from django.test import RequestFactory + +from fecfiler.authentication.token import (login_dot_gov_logout, + generate_username) + + +class TestToken(unittest.TestCase): + + def setUp(self): + self.factory = RequestFactory() + + def test_login_dot_gov_logout_happy_path(self): + test_id_token_hint = 'test_id_token_hint' + test_state = 'test_state' + + mock_request = Mock() + mock_request.session = Mock() + mock_request.session.get.return_value = test_id_token_hint + mock_request.get_signed_cookie.return_value = test_state + + retval = login_dot_gov_logout(mock_request) + self.maxDiff = None + self.assertEqual(retval, ('https://idp.int.identitysandbox.gov' + '/openid_connect/logout?' + 'id_token_hint=test_id_token_hint' + '&post_logout_redirect_uri=None' + '&state=test_state')) + + def test_generate_username(self): + test_uuid = 'test_uuid' + retval = generate_username(test_uuid) + self.assertEqual(test_uuid, retval) diff --git a/django-backend/fecfiler/authentication/token.py b/django-backend/fecfiler/authentication/token.py index bea7c1b04d..8e76dbee06 100644 --- a/django-backend/fecfiler/authentication/token.py +++ b/django-backend/fecfiler/authentication/token.py @@ -4,12 +4,30 @@ from fecfiler.settings import SECRET_KEY import jwt from rest_framework_jwt.compat import get_username_field, get_username -from rest_framework_jwt.settings import api_settings +from rest_framework_jwt.settings import api_settings, settings import logging +from urllib.parse import urlencode logger = logging.getLogger(__name__) +def login_dot_gov_logout(request): + id_token_hint = request.session.get("oidc_id_token") + post_logout_redirect_uri = settings.LOGOUT_REDIRECT_URL + state = request.get_signed_cookie('oidc_state') + + params = { + 'id_token_hint': id_token_hint, + 'post_logout_redirect_uri': post_logout_redirect_uri, + 'state': state, + } + query = urlencode(params) + op_logout_url = settings.OIDC_OP_LOGOUT_ENDPOINT + redirect_url = '{url}?{query}'.format(url=op_logout_url, query=query) + + return redirect_url + + def generate_username(uuid): return uuid diff --git a/django-backend/fecfiler/authentication/views.py b/django-backend/fecfiler/authentication/views.py index 1c57482239..69f2eefd2b 100644 --- a/django-backend/fecfiler/authentication/views.py +++ b/django-backend/fecfiler/authentication/views.py @@ -1,5 +1,5 @@ from django.views.generic import View -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponseRedirect from fecfiler.settings import ( LOGIN_REDIRECT_CLIENT_URL, @@ -65,9 +65,11 @@ def get(self, request, *args, **kwargs): class LoginDotGovSuccessLogoutSpaRedirect(View): def get(self, request, *args, **kwargs): - response = HttpResponse(status=204) # no content + response = HttpResponseRedirect(LOGIN_REDIRECT_CLIENT_URL) response.delete_cookie(FFAPI_COMMITTEE_ID_COOKIE_NAME, domain=FFAPI_COOKIE_DOMAIN) response.delete_cookie(FFAPI_EMAIL_COOKIE_NAME, domain=FFAPI_COOKIE_DOMAIN) + response.delete_cookie('csrftoken', + domain=FFAPI_COOKIE_DOMAIN) return response diff --git a/django-backend/fecfiler/contacts/test_serializers.py b/django-backend/fecfiler/contacts/test_serializers.py index 8ff03f52d8..41763fbe8a 100644 --- a/django-backend/fecfiler/contacts/test_serializers.py +++ b/django-backend/fecfiler/contacts/test_serializers.py @@ -18,6 +18,7 @@ def setUp(self): "state": "St", "zip": "123456789", "country": "Country", + "telephone": "+1 1234567890", } self.invalid_contact = { @@ -26,6 +27,7 @@ def setUp(self): "first_name": "First", "street_1": "Street", "city": "City", + "country": "USA", } self.mock_request = Request(HttpRequest()) @@ -46,7 +48,6 @@ def test_serializer_validate(self): self.assertFalse(invalid_serializer.is_valid()) self.assertIsNotNone(invalid_serializer.errors["state"]) self.assertIsNotNone(invalid_serializer.errors["zip"]) - self.assertIsNotNone(invalid_serializer.errors["country"]) def test_read_only_fields(self): update = self.valid_contact.copy() diff --git a/django-backend/fecfiler/contacts/test_views.py b/django-backend/fecfiler/contacts/test_views.py new file mode 100644 index 0000000000..2e2015c47d --- /dev/null +++ b/django-backend/fecfiler/contacts/test_views.py @@ -0,0 +1,51 @@ +from django.test import TestCase, RequestFactory +from .views import ContactViewSet +from ..authentication.models import Account +from unittest import mock + + +def mocked_requests_get(*args, **kwargs): + class MockResponse: + def __init__(self, json_data, status_code): + self.json_data = json_data + self.status_code = status_code + + def json(self): + return self.json_data + + return MockResponse( + { + "results": [ + {"name": "BIDEN FOR PRESIDENT", "id": "C00703975", "is_active": "true"}, + {"name": "BIDEN VICTORY FUND", "id": "C00744946", "is_active": "true"}, + ] + }, + 200, + ) + + +class ContactViewSetTest(TestCase): + fixtures = ["test_contacts", "test_committee_accounts", "test_accounts"] + + def setUp(self): + self.user = Account.objects.get(cmtee_id="C12345678") + self.factory = RequestFactory() + + @mock.patch("requests.get", side_effect=mocked_requests_get) + def test_committee_lookup_happy_path(self, mock_get): + self.assertEqual(True, True) + request = self.factory.get("/api/v1/contacts/committee_lookup") + request.user = self.user + + response = ContactViewSet.as_view({"get": "committee_lookup"})(request) + + expected_json = { + "fec_api_committees": [ + {"name": "BIDEN FOR PRESIDENT", "id": "C00703975", "is_active": "true"}, + {"name": "BIDEN VICTORY FUND", "id": "C00744946", "is_active": "true"}, + ], + "fecfile_committees": [], + } + + self.assertEqual(response.status_code, 200) + self.assertJSONEqual(str(response.content, encoding="utf8"), expected_json) diff --git a/django-backend/fecfiler/contacts/views.py b/django-backend/fecfiler/contacts/views.py index 872c582669..e9fe455806 100644 --- a/django-backend/fecfiler/contacts/views.py +++ b/django-backend/fecfiler/contacts/views.py @@ -1,6 +1,12 @@ +from django.http import HttpResponseBadRequest, JsonResponse +from django.db.models import Q, F +from rest_framework.decorators import action +from fecfiler.settings import FEC_API_KEY, FEC_API_COMMITTEE_LOOKUP_ENDPOINT +from urllib.parse import urlencode from fecfiler.committee_accounts.views import CommitteeOwnedViewSet from .models import Contact from .serializers import ContactSerializer +import requests import logging logger = logging.getLogger(__name__) @@ -20,3 +26,49 @@ class ContactViewSet(CommitteeOwnedViewSet): in CommitteeOwnedViewSet's implementation of get_queryset() """ queryset = Contact.objects.all().order_by("-id") + + @action(detail=False) + def committee_lookup(self, request): + q = request.GET.get("q", "") + if q is None: + return HttpResponseBadRequest() + + max_fec_results = 10 + max_fec_results_param = request.GET.get("max_fec_results", "") + if max_fec_results_param is not None and max_fec_results_param.isnumeric(): + max_fec_results = int(max_fec_results_param) + + max_fecfile_results = 10 + max_fecfile_results_param = request.GET.get("max_fecfile_results", "") + if ( + max_fecfile_results_param is not None + and max_fecfile_results_param.isnumeric() + ): + max_fecfile_results = int(max_fecfile_results_param) + + query_params = urlencode( + { + "q": q, + "api_key": FEC_API_KEY, + } + ) + url = "{url}?{query_params}".format( + url=FEC_API_COMMITTEE_LOOKUP_ENDPOINT, query_params=query_params + ) + json_results = requests.get(url).json() + + fec_api_committees = json_results.get("results", [])[:max_fec_results] + fecfile_committees = list( + self.get_queryset() + .filter(Q(committee_id__icontains=q) | Q(name__icontains=q)) + .order_by("-committee_id") + .annotate(result_id=F("committee_id")) + .values("name", "result_id") + .annotate(id=F("result_id"))[:max_fecfile_results] + ) + return_value = { + "fec_api_committees": fec_api_committees, + "fecfile_committees": fecfile_committees, + } + + return JsonResponse(return_value) diff --git a/django-backend/fecfiler/f3x_summaries/__init__.py b/django-backend/fecfiler/f3x_summaries/__init__.py index e69de29bb2..14e75db0bb 100644 --- a/django-backend/fecfiler/f3x_summaries/__init__.py +++ b/django-backend/fecfiler/f3x_summaries/__init__.py @@ -0,0 +1,11 @@ +"""Django App for F3X Reports + +Provides F3X Summary record +:py:class:`fecfiler.f3x_summaries.models.F3XSummary` + +Also provides abstract model and viewset for +apps that need report-related records + +:py:class:`fecfiler.committee_accounts.models.ReportMixin` +:py:class:`fecfiler.committee_accounts.views.ReportViewMixin` +""" diff --git a/django-backend/fecfiler/f3x_summaries/fixtures/test_summary_transactions.json b/django-backend/fecfiler/f3x_summaries/fixtures/test_summary_transactions.json new file mode 100644 index 0000000000..2474178873 --- /dev/null +++ b/django-backend/fecfiler/f3x_summaries/fixtures/test_summary_transactions.json @@ -0,0 +1,62 @@ +[ + { + "comment": "SA15 transaction to count", + "model": "scha_transactions.schatransaction", + "fields": { + "id": 9999, + "committee_account_id": 1000, + "report_id":9999, + "form_type": "SA15", + "contribution_amount":1234.56, + "memo_code": false, + "created": "2022-02-09T00:00:00.000Z", + "updated": "2022-02-09T00:00:00.000Z", + "transaction_type_identifier":"OFFSET_TO_OPEX" + } + }, + { + "comment": "SA15 transaction to count", + "model": "scha_transactions.schatransaction", + "fields": { + "id": 10000, + "committee_account_id": 1000, + "report_id":9999, + "form_type": "SA15", + "contribution_amount":891.23, + "memo_code": false, + "created": "2022-02-09T00:00:00.000Z", + "updated": "2022-02-09T00:00:00.000Z", + "transaction_type_identifier":"OFFSET_TO_OPEX" + } + }, + { + "comment": "SA15 transaction to NOT count", + "model": "scha_transactions.schatransaction", + "fields": { + "id": 10001, + "committee_account_id": 1000, + "report_id":9999, + "form_type": "SA15", + "contribution_amount":10000.23, + "memo_code": true, + "created": "2022-02-09T00:00:00.000Z", + "updated": "2022-02-09T00:00:00.000Z", + "transaction_type_identifier":"OFFSET_TO_OPEX" + } + }, + { + "comment": "SA11AI transaction to NOT count with SA15", + "model": "scha_transactions.schatransaction", + "fields": { + "id": 10002, + "committee_account_id": 1000, + "report_id":9999, + "form_type": "SA11AI", + "contribution_amount":10000.23, + "memo_code": false, + "created": "2022-02-09T00:00:00.000Z", + "updated": "2022-02-09T00:00:00.000Z", + "transaction_type_identifier":"INDV_REC" + } + } +] \ No newline at end of file diff --git a/django-backend/fecfiler/f3x_summaries/migrations/0017_f3xsummary_calculation_status.py b/django-backend/fecfiler/f3x_summaries/migrations/0017_f3xsummary_calculation_status.py new file mode 100644 index 0000000000..de8c932102 --- /dev/null +++ b/django-backend/fecfiler/f3x_summaries/migrations/0017_f3xsummary_calculation_status.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.12 on 2022-09-01 20:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('f3x_summaries', '0016_f3xsummary_webprint_submission'), + ] + + operations = [ + migrations.AddField( + model_name='f3xsummary', + name='calculation_status', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/django-backend/fecfiler/f3x_summaries/models.py b/django-backend/fecfiler/f3x_summaries/models.py index 33a021b5d7..58dbe77ff4 100644 --- a/django-backend/fecfiler/f3x_summaries/models.py +++ b/django-backend/fecfiler/f3x_summaries/models.py @@ -1,6 +1,10 @@ from django.db import models from fecfiler.soft_delete.models import SoftDeleteModel from fecfiler.committee_accounts.models import CommitteeOwnedModel +import logging + + +logger = logging.getLogger(__name__) class ReportCodeLabel(models.Model): @@ -370,6 +374,9 @@ class F3XSummary(SoftDeleteModel, CommitteeOwnedModel): null=True, blank=True, ) + + calculation_status = models.CharField(max_length=255, null=True, blank=True) + created = models.DateTimeField(auto_now_add=True) updated = models.DateTimeField(auto_now=True) @@ -388,5 +395,13 @@ class ReportMixin(models.Model): "f3x_summaries.F3XSummary", on_delete=models.CASCADE, null=True, blank=True ) + def save(self, *args, **kwargs): + if self.report: + self.report.calculation_status = None + self.report.save() + logger.info(f"F3X Summary: {self.report.id} marked for recalcuation") + + super(ReportMixin, self).save(*args, **kwargs) + class Meta: abstract = True diff --git a/django-backend/fecfiler/f3x_summaries/serializers.py b/django-backend/fecfiler/f3x_summaries/serializers.py index 92b14a0926..ebb62fda5d 100644 --- a/django-backend/fecfiler/f3x_summaries/serializers.py +++ b/django-backend/fecfiler/f3x_summaries/serializers.py @@ -1,5 +1,10 @@ from .models import F3XSummary, ReportCodeLabel -from rest_framework.serializers import ModelSerializer, SlugRelatedField, EmailField +from rest_framework.serializers import ( + ModelSerializer, + SlugRelatedField, + EmailField, + CharField +) from fecfiler.committee_accounts.serializers import CommitteeOwnedSerializer from fecfiler.web_services.serializers import ( UploadSubmissionSerializer, @@ -40,6 +45,9 @@ class F3XSummarySerializer(CommitteeOwnedSerializer, FecSchemaValidatorSerialize webprint_submission = WebPrintSubmissionSerializer( read_only=True, ) + report_status = CharField( + read_only=True, + ) class Meta: model = F3XSummary @@ -55,7 +63,7 @@ class Meta: "uploadsubmission", "webprintsubmission", ] - ] + ] + ["report_status"] read_only_fields = [ "id", "deleted", diff --git a/django-backend/fecfiler/f3x_summaries/test_serializers.py b/django-backend/fecfiler/f3x_summaries/test_serializers.py index a8d812f226..251a5c825b 100644 --- a/django-backend/fecfiler/f3x_summaries/test_serializers.py +++ b/django-backend/fecfiler/f3x_summaries/test_serializers.py @@ -14,6 +14,9 @@ def setUp(self): "treasurer_last_name": "Validlastname", "treasurer_first_name": "Validfirstname", "date_signed": "2022-01-01", + "upload_submission": { + "fec_status": " ACCEPTED" + } } self.invalid_f3x_summary = { diff --git a/django-backend/fecfiler/f3x_summaries/views.py b/django-backend/fecfiler/f3x_summaries/views.py index 39a7b45970..40e4f0451e 100644 --- a/django-backend/fecfiler/f3x_summaries/views.py +++ b/django-backend/fecfiler/f3x_summaries/views.py @@ -7,9 +7,11 @@ from fecfiler.committee_accounts.views import CommitteeOwnedViewSet from .models import F3XSummary, ReportCodeLabel from fecfiler.scha_transactions.models import SchATransaction +from fecfiler.web_services.models import FECSubmissionState, FECStatus from fecfiler.memo_text.models import MemoText from fecfiler.web_services.models import DotFEC, UploadSubmission, WebPrintSubmission from .serializers import F3XSummarySerializer, ReportCodeLabelSerializer +from django.db.models import Case, Value, When import logging logger = logging.getLogger(__name__) @@ -25,13 +27,50 @@ class F3XSummaryViewSet(CommitteeOwnedViewSet): in CommitteeOwnedViewSet's implementation of get_queryset() """ - queryset = F3XSummary.objects.select_related("report_code").all() + queryset = F3XSummary.objects.select_related("report_code").annotate( + report_status=Case( + When(upload_submission=None, then=Value('In-Progress')), + When( + upload_submission__fecfile_task_state=FECSubmissionState.INITIALIZING, + then=Value('Submitted') + ), + When( + upload_submission__fecfile_task_state=FECSubmissionState.CREATING_FILE, + then=Value('Submitted') + ), + When( + upload_submission__fecfile_task_state=FECSubmissionState.SUBMITTING, + then=Value('Submitted') + ), + When( + upload_submission__fecfile_task_state=FECSubmissionState.FAILED, + then=Value('Failed') + ), + When( + upload_submission__fec_status=FECStatus.ACCEPTED, + then=Value('Submitted') + ), + When( + upload_submission__fec_status=FECStatus.PROCESSING, + then=Value('Submitted') + ), + When( + upload_submission__fec_status=FECStatus.REJECTED, + then=Value('Rejected') + ), + When(upload_submission__fec_status=None, then=Value('In-Progress')), + When(upload_submission__fec_status='', then=Value('In-Progress')), + ) + ).all() """Join on report code labels""" serializer_class = F3XSummarySerializer permission_classes = [] filter_backends = [filters.OrderingFilter] - ordering_fields = ["form_type", "report_code__label", "coverage_through_date"] + ordering_fields = [ + "form_type", "report_code__label", "coverage_through_date", + "upload_submission__fec_status", "submission_status" + ] ordering = ["form_type"] @action(detail=False) diff --git a/django-backend/fecfiler/settings/base.py b/django-backend/fecfiler/settings/base.py index fdb96a5264..720a0cb07b 100644 --- a/django-backend/fecfiler/settings/base.py +++ b/django-backend/fecfiler/settings/base.py @@ -180,6 +180,8 @@ OIDC_OP_AUTHORIZATION_ENDPOINT = OIDC_OP_CONFIG.get("authorization_endpoint") OIDC_OP_TOKEN_ENDPOINT = OIDC_OP_CONFIG.get("token_endpoint") OIDC_OP_USER_ENDPOINT = OIDC_OP_CONFIG.get("userinfo_endpoint") +OIDC_OP_LOGOUT_ENDPOINT = OIDC_OP_CONFIG.get("end_session_endpoint") +ALLOW_LOGOUT_GET_METHOD = True # TODO: Env vars? FFAPI_COMMITTEE_ID_COOKIE_NAME = "ffapi_committee_id" @@ -194,6 +196,8 @@ "acr_values": "http://idmanagement.gov/ns/assurance/ial/1" } +OIDC_OP_LOGOUT_URL_METHOD = "fecfiler.authentication.token.login_dot_gov_logout" + OIDC_USERNAME_ALGO = "fecfiler.authentication.token.generate_username" # OIDC settings end @@ -280,3 +284,9 @@ AWS_SECRET_ACCESS_KEY = env.get_credential("AWS_SECRET_ACCESS_KEY") AWS_STORAGE_BUCKET_NAME = env.get_credential("AWS_STORAGE_BUCKET_NAME") AWS_REGION = env.get_credential("AWS_REGION") + +"""FEC API settings +""" +FEC_API = env.get_credential("FEC_API") +FEC_API_KEY = env.get_credential("FEC_API_KEY") +FEC_API_COMMITTEE_LOOKUP_ENDPOINT = str(FEC_API) + "names/committees/" diff --git a/django-backend/fecfiler/web_services/summary/__init__.py b/django-backend/fecfiler/web_services/summary/__init__.py new file mode 100644 index 0000000000..a4da3a20e6 --- /dev/null +++ b/django-backend/fecfiler/web_services/summary/__init__.py @@ -0,0 +1,2 @@ +"""Summary Module provides a SummaryService that calculates +totals expected in the report summary""" diff --git a/django-backend/fecfiler/web_services/summary/summary.py b/django-backend/fecfiler/web_services/summary/summary.py new file mode 100644 index 0000000000..cf772bd55e --- /dev/null +++ b/django-backend/fecfiler/web_services/summary/summary.py @@ -0,0 +1,23 @@ +from decimal import Decimal +from fecfiler.scha_transactions.models import SchATransaction +from django.db.models import Q, Sum +from django.db.models.functions import Coalesce + + +class SummaryService: + def __init__(self, report) -> None: + self.report = report + + def calculate_summary(self): + report_transactions = SchATransaction.objects.filter(report=self.report) + # just line 15 + sa15_query = Q(form_type="SA15", memo_code=False) + line_15 = self._create_contribution_sum(sa15_query) + summary = report_transactions.aggregate(line_15=line_15) + return summary + + def _create_contribution_sum(self, query): + return Coalesce( + Sum("contribution_amount", filter=query), + Decimal(0.0), + ) diff --git a/django-backend/fecfiler/web_services/summary/tasks.py b/django-backend/fecfiler/web_services/summary/tasks.py new file mode 100644 index 0000000000..56bb2e363d --- /dev/null +++ b/django-backend/fecfiler/web_services/summary/tasks.py @@ -0,0 +1,36 @@ +from enum import Enum +from celery import shared_task +from fecfiler.f3x_summaries.models import F3XSummary +from .summary import SummaryService + +import logging + +logger = logging.getLogger(__name__) + + +class CalculationState(Enum): + """States of calculating summary""" + + CALCULATING = "CALCULATING" + SUCCEEDED = "SUCCEEDED" + FAILED = "FAILED" + + def __str__(self): + return str(self.value) + + +@shared_task +def calculate_summary(report_id): + try: + report = F3XSummary.objects.get(id=report_id) + except Exception: + return None + report.calculation_status = CalculationState.CALCULATING + report.save() + summary_service = SummaryService(report) + summary = summary_service.calculate_summary() + report.L15_offsets_to_operating_expenditures_refunds_period = summary["line_15"] + report.L37_offsets_to_operating_expenditures_period = summary["line_15"] + report.calculation_status = CalculationState.SUCCEEDED + report.save() + return report diff --git a/django-backend/fecfiler/web_services/summary/test_summary.py b/django-backend/fecfiler/web_services/summary/test_summary.py new file mode 100644 index 0000000000..95c7262014 --- /dev/null +++ b/django-backend/fecfiler/web_services/summary/test_summary.py @@ -0,0 +1,24 @@ +from decimal import Decimal +from django.test import TestCase +from fecfiler.f3x_summaries.models import F3XSummary +from .summary import SummaryService + + +class F3XSerializerTestCase(TestCase): + fixtures = [ + "test_committee_accounts", + "test_f3x_summaries", + "test_summary_transactions", + ] + + def test_calculate_summary(self): + f3x = F3XSummary.objects.get(id=9999) + summary_service = SummaryService(f3x) + summary = summary_service.calculate_summary() + self.assertEqual(summary["line_15"], Decimal("2125.79")) + + def test_report_with_no_transactions(self): + f3x = F3XSummary.objects.get(id=10000) + summary_service = SummaryService(f3x) + summary = summary_service.calculate_summary() + self.assertEqual(summary["line_15"], Decimal("0")) diff --git a/django-backend/fecfiler/web_services/summary/test_tasks.py b/django-backend/fecfiler/web_services/summary/test_tasks.py new file mode 100644 index 0000000000..5d7541f0aa --- /dev/null +++ b/django-backend/fecfiler/web_services/summary/test_tasks.py @@ -0,0 +1,36 @@ +from decimal import Decimal +from django.test import TestCase +from .tasks import CalculationState, calculate_summary + + +class F3XSerializerTestCase(TestCase): + fixtures = [ + "test_committee_accounts", + "test_f3x_summaries", + "test_summary_transactions", + ] + + def test_summary_task(self): + report = calculate_summary(999999999) + self.assertIsNone(report) + + report = calculate_summary(9999) + self.assertEqual( + report.L15_offsets_to_operating_expenditures_refunds_period, + Decimal("2125.79"), + ) + self.assertEqual( + report.L37_offsets_to_operating_expenditures_period, Decimal("2125.79") + ) + self.assertEqual(report.calculation_status, CalculationState.SUCCEEDED) + + def test_report_with_no_transactions(self): + report = calculate_summary(10000) + self.assertEqual( + report.L15_offsets_to_operating_expenditures_refunds_period, + Decimal("0"), + ) + self.assertEqual( + report.L37_offsets_to_operating_expenditures_period, Decimal("0") + ) + self.assertEqual(report.calculation_status, CalculationState.SUCCEEDED) diff --git a/django-backend/fecfiler/web_services/summary/views.py b/django-backend/fecfiler/web_services/summary/views.py new file mode 100644 index 0000000000..a331e1b051 --- /dev/null +++ b/django-backend/fecfiler/web_services/summary/views.py @@ -0,0 +1,36 @@ +from fecfiler.f3x_summaries.models import F3XSummary +from rest_framework import viewsets, status +from rest_framework.response import Response +from rest_framework.decorators import action +from .tasks import calculate_summary, CalculationState +from ..serializers import ReportIdSerializer + +import logging + +logger = logging.getLogger(__name__) + + +class SummaryViewSet(viewsets.ViewSet): + """ + A viewset that provides actions to start summary calculation tasks and + retrieve their statuses and results + """ + + @action( + detail=False, + methods=["post"], + url_path="calculate-summary", + ) + def calculate_summary(self, request): + """ """ + serializer = ReportIdSerializer(data=request.data, context={"request": request}) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + report_id = serializer.validated_data["report_id"] + report = F3XSummary.objects.get(id=report_id) + report.calculation_status = CalculationState.CALCULATING + report.save() + logger.debug(f"Starting Celery Task calculate_summary for report :{report_id}") + task = calculate_summary.apply_async((report_id,), retry=False) + logger.debug(f"Status from calculate_summary report {report_id}: {task.status}") + return Response({"status": "summary task created"}) diff --git a/django-backend/fecfiler/web_services/urls.py b/django-backend/fecfiler/web_services/urls.py index 81ad54b64d..28104c81d2 100644 --- a/django-backend/fecfiler/web_services/urls.py +++ b/django-backend/fecfiler/web_services/urls.py @@ -1,10 +1,12 @@ from django.urls import path, include from rest_framework.routers import DefaultRouter from .views import WebServicesViewSet +from .summary.views import SummaryViewSet # Create a router and register our viewsets with it. router = DefaultRouter() router.register(r"web-services", WebServicesViewSet, basename="web-services") +router.register(r"web-services/summary", SummaryViewSet, basename="summary") # The API URLs are now determined automatically by the router. urlpatterns = [ diff --git a/docker-compose.yml b/docker-compose.yml index d9568cd457..8a7e370b1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -100,3 +100,5 @@ services: FILE_AS_TEST_COMMITTEE: TEST_COMMITTEE_PASSWORD: WEBPRINT_EMAIL: + FEC_API: + FEC_API_KEY: diff --git a/requirements.txt b/requirements.txt index 68c6210d2b..af49784961 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ django-storages==1.12.3 djangorestframework==3.13.1 djangorestframework-jwt==1.11.0 drf-spectacular==0.21.2 -git+https://github.com/fecgov/fecfile-validate@1f02f18#egg=fecfile_validate&subdirectory=fecfile_validate_python +git+https://github.com/fecgov/fecfile-validate@54e19d7#egg=fecfile_validate&subdirectory=fecfile_validate_python GitPython==3.1.27 gunicorn==19.10.0 Jinja2==2.11.3