Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
681a933
Merge pull request #2203 from fecgov/feature/3358-false_flag_operations
toddlees Aug 13, 2026
f1ab283
FECFILE-3332: Set min pool size to 0 to avoid hoarding stale connecti…
danguyf Aug 13, 2026
e4e1a4d
FECFILE-3332: Set min pool size to 0 to avoid hoarding stale connecti…
danguyf Aug 13, 2026
e4d560e
FECFILE-3332: Removed unnecessary explicit config to let OOTB default…
danguyf Aug 13, 2026
82e6ce3
Added environment variables to define gunicorn workers and threads
max-zaremba-tcg Aug 17, 2026
0a8f9c5
Merge pull request #2211 from fecgov/hotfix/main-disable-unassociated…
toddlees Aug 17, 2026
bcf2c33
Merge pull request #2216 from fecgov/hotfix/main-restoring-deleted-co…
toddlees Aug 18, 2026
c3c8a5d
Merge pull request #2220 from fecgov/hotfix/unified-hotfix
toddlees Aug 18, 2026
d42b70f
Merge pull request #2244 from fecgov/hotfix/unified-hotfix
lbeaufort Aug 19, 2026
58451b9
Merge pull request #2249 from fecgov/hotfix/unified-hotfix
toddlees Aug 20, 2026
6595183
Merge pull request #2248 from fecgov/hotfix/unified-hotfix
toddlees Aug 20, 2026
a787d37
FECFILE-3346: Update gitpython to 3.1.58
danguyf Aug 21, 2026
fc1ac82
Merge pull request #2255 from fecgov/release/sprint-92
lbeaufort Aug 21, 2026
1c71cc2
Merge pull request #2256 from fecgov/feature/3346-gitpython_update_3.…
lbeaufort Aug 24, 2026
e05bda9
update sqlparse
toddlees Aug 24, 2026
230119d
Merge pull request #2257 from fecgov/hotfix/sqlparse
lbeaufort Aug 24, 2026
7bef0fd
Merge pull request #2208 from fecgov/feature/3332-pool_config_rightsi…
lbeaufort Aug 24, 2026
9d6737e
Merge pull request #2258 from fecgov/hotfix/sqlparse
lbeaufort Aug 24, 2026
e377d40
Removed quotation marks around environment variables
max-zaremba-tcg Aug 24, 2026
5f71f5a
fosec-133 process log tokens
dheitzer Aug 25, 2026
083d46c
Update cryptography package
lbeaufort Aug 25, 2026
b4497bc
Merge pull request #2263 from fecgov/feature/3343-update-cryptography…
lbeaufort Aug 25, 2026
9622376
Update gitpython to latest
lbeaufort Aug 25, 2026
c9ecced
3293 3306 F3 changes
dheitzer Aug 25, 2026
e3d3a3b
Merge pull request #2264 from fecgov/feature/3347-update-gitpython
lbeaufort Aug 25, 2026
2580394
Merge branch 'develop' into fosec-133
dheitzer Aug 25, 2026
ba30b1e
fosec-133 CR comments
dheitzer Aug 25, 2026
4372f00
Merge pull request #2261 from fecgov/fosec-133
dheitzer Aug 25, 2026
737665b
Moved environment varibles from celery-worker to api
max-zaremba-tcg Aug 26, 2026
8d8e625
FECFILE-1236: Added SES service to manifests.
danguyf Aug 26, 2026
1b50196
FECFILE-1236: Renamed email notification function for clarity, switch…
danguyf Aug 26, 2026
395addb
FECFILE-1236: Updated plaintext version.
danguyf Aug 26, 2026
ee5d345
FECFILE-1236: Linted plaintext version.
danguyf Aug 26, 2026
0338cd0
fosec-133 test logging exceptions
dheitzer Aug 26, 2026
cf00f36
Merge pull request #2209 from fecgov/feature/3070
lbeaufort Aug 27, 2026
67a8210
Moving gunicorn env variables to be in defaults rather than ony api
toddlees Aug 27, 2026
de23bf4
Merge pull request #2269 from fecgov/patch/update-gunicorn-env-vars
lbeaufort Aug 27, 2026
b4a0bc6
Merge pull request #2204 from fecgov/release/sprint-92
lbeaufort Aug 27, 2026
09179b3
FECFILE-3391: Add SES service to bindings in manifests.
danguyf Aug 27, 2026
0e0847c
Merge remote-tracking branch 'origin/develop' into feature/1236-HTML_…
danguyf Aug 27, 2026
f8433d2
FECFILE-1236: Moved plaintext email to template.
danguyf Aug 27, 2026
6a0a40c
Merge pull request #2267 from fecgov/fosec-133-cr-comments
lbeaufort Aug 27, 2026
86a3826
Merge pull request #2270 from fecgov/feature/3391-ses_binding_in_mani…
lbeaufort Aug 27, 2026
3fae61b
Merge pull request #2265 from fecgov/feature/3293-3306
lbeaufort Aug 27, 2026
a93af07
FECFILE-1236: Added HTML template.
danguyf Aug 27, 2026
e762014
FECFILE-1236: Refactoring HTML email notification template.
danguyf Aug 28, 2026
b6b398a
FECFILE-1236: Further refactoring HTML email notification template.
danguyf Aug 28, 2026
8fa2f24
turn off email on dev and stage
toddlees Sep 1, 2026
8f1ff89
Merge pull request #2277 from fecgov/hotfix/147-turn-off-email
lbeaufort Sep 1, 2026
ba10513
FECFILE-1236: Updated HTML email template.
danguyf Sep 1, 2026
f389be2
change link style to be dotted
toddlees Sep 1, 2026
bdeeb59
FECFILE-1236: Don't rely on flex, the font is 14px so vertically cent…
danguyf Sep 1, 2026
bae36e9
FECFILE-1236: Simplified CSS, vertical centering of footer works in G…
danguyf Sep 2, 2026
7a0bd5a
Merge pull request #2278 from fecgov/feature/1236-HTML_email_notifica…
toddlees Sep 2, 2026
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
6 changes: 4 additions & 2 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ testem.log
django-backend/.cache/
django-backend/.local/


### Bower ###
*/bower_components
*.bower-cache
Expand Down Expand Up @@ -89,4 +88,7 @@ django-backend/celerybeat-schedule-wal

#profiling
static/
*.prof
*.prof

# exceptions
!django-backend/static/
2 changes: 1 addition & 1 deletion Dockerfile-e2e
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,4 @@ RUN useradd nxgu --no-create-home --home /opt/nxg_fec_e2e && chown -R nxgu:nxgu
USER nxgu

EXPOSE 8080
ENTRYPOINT ["/bin/sh", "-c", "python manage.py migrate && python manage.py loaddata fixtures/user-data.json && python manage.py load_mocked_committee_data && gunicorn --bind 0.0.0.0:8080 fecfiler.wsgi -w 9 --threads=8 --reload"]
ENTRYPOINT ["/bin/sh", "-c", "python manage.py migrate && python manage.py loaddata fixtures/user-data.json && python manage.py load_mocked_committee_data && gunicorn --bind 0.0.0.0:8080 fecfiler.wsgi -w $GUNICORN_WORKERS --threads=$GUNICORN_THREADS --reload"]
2 changes: 1 addition & 1 deletion bin/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,4 @@ python manage.py load_mocked_committee_data

# Start the Gunicorn server
echo "Starting Gunicorn server..."
exec gunicorn --bind 0.0.0.0:8080 fecfiler.wsgi -w 9 --threads=8 --reload
exec gunicorn --bind 0.0.0.0:8080 fecfiler.wsgi -w $GUNICORN_WORKERS --threads=$GUNICORN_THREADS --reload
3 changes: 2 additions & 1 deletion bin/run-api.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ cd django-backend
echo "------ Starting APP ------"

# Run application
./manage.py collectstatic --noinput --traceback --verbosity 3 && exec gunicorn --bind 0.0.0.0:8080 fecfiler.wsgi -w 9 --threads=8
./manage.py collectstatic --noinput --traceback --verbosity 3 && exec gunicorn --bind 0.0.0.0:8080 fecfiler.wsgi -w $GUNICORN_WORKERS --threads=$GUNICORN_THREADS

26 changes: 26 additions & 0 deletions django-backend/fecfiler/committee_accounts/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,32 @@ def test_add_membership_requires_correct_parameters(self):
)
self.assertEqual(response.status_code, 400)

@patch("fecfiler.committee_accounts.views.send_email_notification")
@patch("fecfiler.committee_accounts.views.settings")
def test_add_member_email_template_renders_expected_values(
self, mock_settings, mock_send_email
):
mock_settings.SPACE = "local"

viewset = CommitteeMembershipViewSet()
viewset.sendAddMemberEmailNotification(
committee_id="C12345678",
committee_name="Example Committee",
email="invitee@fec.gov",
full_name="Alex Admin",
role=Membership.CommitteeRole.COMMITTEE_ADMINISTRATOR,
)

self.assertEqual(mock_send_email.call_count, 1)
body_text = mock_send_email.call_args.kwargs["body_text"]

self.assertIn("Alex Admin has added you as a", body_text)
self.assertIn("Committee ID: C12345678", body_text)
self.assertIn("Committee Name: Example Committee", body_text)
self.assertIn("http://localhost:4200/login", body_text)
self.assertNotIn("{{", body_text)
self.assertNotIn("}}", body_text)

def test_update_membership_forbidden(self):
user = User.objects.get(id="fb20ffc3-285e-448e-9e56-9ca1fd43e7d3")
response = self.send_viewset_put_request(
Expand Down
57 changes: 38 additions & 19 deletions django-backend/fecfiler/committee_accounts/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
HttpResponseBadRequest,
HttpResponseServerError,
)
from django.template.loader import render_to_string
from django.core.exceptions import ValidationError
from drf_spectacular.utils import extend_schema, OpenApiParameter
from .serializers import CommitteeAccountSerializer, CommitteeMembershipSerializer
Expand Down Expand Up @@ -242,16 +243,22 @@ def add_member(self, request):

# if no Exception was returned, send email notification to the user
if not isinstance(new_member, BaseException):
# fall back to email if name is unavailable
full_name = request.user.get_full_name() or request.user.email
logger.info(
f"User {request.user.first_name} added {email} to committee "
f"User {full_name} added {email} to committee "
f"{committee_id} as {role}"
)
if FLAG__ENABLE_EMAIL:
self.sendAddUserToCommitteeEmail(
committee_data = get_committee_account_data(committee_id)
committee_name = committee_data.get("name", None)

self.sendAddMemberEmailNotification(
committee_id,
committee_name,
email,
request.user.first_name,
role
full_name,
role,
)
else:
logger.error(
Expand Down Expand Up @@ -362,28 +369,40 @@ def list(self, request, *args, **kwargs):
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data)

def sendAddUserToCommitteeEmail(self, committee_id, email, first_name, role):
subject = f"[FECfile+] Invite to committee {committee_id}"
def sendAddMemberEmailNotification(
self, committee_id, committee_name, email, full_name, role
):
subject = f"{full_name} has added you to a FECfile+ committee account"

# adjust links based on space
if settings.SPACE == "prod":
envbit = ""
if settings.SPACE == "local":
fecfile_link = "http://localhost:4200"
else:
envbit = f"{settings.SPACE}."

body_text = (
"ADDED TO FECfile+ COMMITTEE\n"
"\n"
f"{first_name} has added you as a {role} "
f"to {committee_id}.\n"
"\n"
"You can access the committee account by signing in to FECfile+:\n"
f"https://{envbit}fecfile.fec.gov/"
if not settings.SPACE or settings.SPACE == "prod":
envbit = ""
else:
envbit = f"{settings.SPACE}."
fecfile_link = f"https://{envbit}fecfile.fec.gov"

email_dict = {
"full_name": full_name,
"role": role,
"committee_id": committee_id,
"committee_name": committee_name,
"fecfile_link": fecfile_link,
}
body_text = render_to_string(
"emails/add_member_notification.txt",
email_dict,
)
body_html = render_to_string(
"emails/add_member_notification.html",
email_dict,
)

try:
send_email_notification(
to_email=email, subject=subject, body_text=body_text
to_email=email, subject=subject, body_text=body_text, body_html=body_html
)
except Exception as e:
logger.error(
Expand Down
96 changes: 70 additions & 26 deletions django-backend/fecfiler/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import structlog
import logging
import sys
import re

from copy import deepcopy
from enum import Enum
from .env import env
from fecfiler.shared.utilities import get_float_from_string, get_boolean_from_string
Expand Down Expand Up @@ -174,17 +176,19 @@ def custom_silk_filter(request):
database = dj_database_url.config()
database.setdefault("OPTIONS", {})
database["OPTIONS"]["application_name"] = f"{APPLICATION_NAME}_{APPLICATION_INDEX}"
database["CONN_HEALTH_CHECKS"] = True

# psycopg pool settings:
# https://www.psycopg.org/psycopg3/docs/api/pool.html#psycopg_pool.ConnectionPool
# this works for celery workers as well
# https://docs.celeryq.dev/en/main/django/first-steps-with-django.html#django-connection-pool
database["OPTIONS"]["pool"] = {
# min_size: psycopg default is 4
"min_size": int(env.get_credential("DB_POOL_MIN_SIZE", "4")),
"min_size": int(env.get_credential("DB_POOL_MIN_SIZE", "0")),
# max_size: default to no overflow
"max_size": int(env.get_credential("DB_POOL_MAX_SIZE", "4")),
# max_idle: psycopg default 10 minutes
"max_idle": int(env.get_credential("DB_POOL_MAX_IDLE", "600")),
# max_idle: psycopg default is 10m, prune before 350s AWS timeout
"max_idle": int(env.get_credential("DB_POOL_MAX_IDLE", "300")),
}

# Database
Expand Down Expand Up @@ -290,33 +294,45 @@ def filter(self, record):

def get_logging_config(log_format=LINE):
stream_handler = "logging.StreamHandler"
json_formatter = {
"()": structlog.stdlib.ProcessorFormatter,
"processors": [
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.processors.ExceptionRenderer(
structlog.processors.ExceptionDictTransformer(show_locals=False)
),
structlog.processors.JSONRenderer(),
],
}
psycopg_json_formatter = deepcopy(json_formatter)
psycopg_json_formatter["processors"] = [process_log_tokens] + psycopg_json_formatter[
"processors"
]
plain_console_formatter = {
"()": structlog.stdlib.ProcessorFormatter,
"processors": [
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.processors.dict_tracebacks,
structlog.dev.ConsoleRenderer(
colors=True, exception_formatter=structlog.dev.rich_traceback
),
],
"foreign_pre_chain": [
structlog.contextvars.merge_contextvars,
],
}
psycopg_plain_console_formatter = deepcopy(plain_console_formatter)
psycopg_plain_console_formatter["processors"] = [
process_log_tokens
] + psycopg_plain_console_formatter["processors"]
logging_config = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"json_formatter": {
"()": structlog.stdlib.ProcessorFormatter,
"processors": [
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.processors.ExceptionRenderer(
structlog.processors.ExceptionDictTransformer(show_locals=False)
),
structlog.processors.JSONRenderer(),
],
},
"plain_console": {
"()": structlog.stdlib.ProcessorFormatter,
"processors": [
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.processors.dict_tracebacks,
structlog.dev.ConsoleRenderer(
colors=True, exception_formatter=structlog.dev.rich_traceback
),
],
"foreign_pre_chain": [
structlog.contextvars.merge_contextvars,
],
},
"json_formatter": json_formatter,
"psycopg_json_formatter": psycopg_json_formatter,
"plain_console": plain_console_formatter,
"psycopg_plain_console": psycopg_plain_console_formatter,
"key_value": {
"()": structlog.stdlib.ProcessorFormatter,
"processors": [
Expand Down Expand Up @@ -350,6 +366,11 @@ def get_logging_config(log_format=LINE):
"formatter": "plain_console",
"stream": sys.stderr,
},
"psycopg_console": {
"class": stream_handler,
"formatter": "psycopg_plain_console",
"stream": sys.stdout,
},
"cloud": {
"class": stream_handler,
"formatter": "json_formatter",
Expand All @@ -362,6 +383,11 @@ def get_logging_config(log_format=LINE):
"formatter": "json_formatter",
"stream": sys.stderr,
},
"psycopg_cloud": {
"class": stream_handler,
"formatter": "psycopg_json_formatter",
"stream": sys.stdout,
},
},
}

Expand All @@ -375,6 +401,11 @@ def get_logging_config(log_format=LINE):
"handlers": ["console", "console_error"],
"level": "DEBUG",
},
"psycopg": {
"handlers": [
"psycopg_console",
],
},
}
if ENABLE_PL_SQL_LOGGING is True:
logging_config["loggers"]["django.db.backends"] = {
Expand All @@ -391,6 +422,11 @@ def get_logging_config(log_format=LINE):
"handlers": ["cloud", "cloud_error"],
"level": "INFO",
},
"psycopg": {
"handlers": [
"psycopg_cloud",
],
},
}

return logging_config
Expand All @@ -402,6 +438,14 @@ def add_migration_logs(logger: logging.Logger, method_name: str, event_dict):
return event_dict


def process_log_tokens(logger, method_name, event_dict):
event_dict["event"] = re.sub(r'user ".*?"', 'user "****"', event_dict["event"])
event_dict["event"] = re.sub(
r'database ".*?"', 'database "****"', event_dict["event"]
)
return event_dict


def get_logging_processors():
"""
get structlog processors
Expand Down
61 changes: 61 additions & 0 deletions django-backend/fecfiler/settings/tests/test_logger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import logging.config
from django.test import TestCase
from django.core.exceptions import ValidationError
import logging
from fecfiler import settings
import structlog
from io import StringIO
from contextlib import redirect_stdout, redirect_stderr


class LoggerTestCase(TestCase):

# Local

def test_info_logging_local(self):
buffer = StringIO()
with redirect_stdout(buffer):
logging.config.dictConfig(settings.get_logging_config("LINE"))
logger = structlog.get_logger()
logger.info("Test info log message")
output = buffer.getvalue()
self.assertIn("Test info log message", output)

def test_exception_logging_local(self):
buffer = StringIO()
with redirect_stderr(buffer):
logging.config.dictConfig(settings.get_logging_config("LINE"))
logger = structlog.get_logger()
try:
raise ValidationError("Test exception")
except ValidationError as e:
logger.exception(e)
output = buffer.getvalue()
self.assertIn("Test exception", output)

# Cloud

def test_info_logging_cloud(self):
buffer = StringIO()
with redirect_stdout(buffer):
logging.config.dictConfig(settings.get_logging_config("NOT_LINE"))
logger = structlog.get_logger()
logger.info("Test info log message")
output = buffer.getvalue()
self.assertIn("Test info log message", output)

def test_exception_logging_cloud(self):
buffer = StringIO()
with redirect_stderr(buffer):
logging.config.dictConfig(settings.get_logging_config("NOT_LINE"))
logger = structlog.get_logger()
try:
raise ValidationError("Test exception1")
except ValidationError as e:
try:
raise ValidationError("Test exception2") from e
except ValidationError as e2:
logger.exception(e2)
output = buffer.getvalue()
self.assertIn("Test exception1", output)
self.assertIn("Test exception2", output)
Loading