Skip to content

Commit

Permalink
Merge pull request #492 from UW-GAC/feature/app-settings-defaults
Browse files Browse the repository at this point in the history
Store app settings (with some defaults in a separate file)
  • Loading branch information
amstilp authored Jun 5, 2024
2 parents 38fd0b7 + c75908c commit de6c75b
Show file tree
Hide file tree
Showing 16 changed files with 273 additions and 85 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
* Drop support for Django 3.2.
* Add support for Django 5.0.
* Add `convert_mariadb_uuid_fields` command to convert UUID fields for MariaDB 10.7+ and Django 5.0+. See the documentation of this command for more information.
* Move app settings to their own file, and set defaults for some settings.

## 0.23.0 (2024-05-31)

Expand Down
2 changes: 1 addition & 1 deletion anvil_consortium_manager/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "0.23.1.dev2"
__version__ = "0.23.1.dev3"
5 changes: 2 additions & 3 deletions anvil_consortium_manager/adapters/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@

from abc import ABC, abstractproperty

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string
from django_filters import FilterSet

from .. import models
from .. import app_settings, models


class BaseAccountAdapter(ABC):
Expand Down Expand Up @@ -53,5 +52,5 @@ def get_autocomplete_label(self, account):


def get_account_adapter():
adapter = import_string(settings.ANVIL_ACCOUNT_ADAPTER)
adapter = import_string(app_settings.ACCOUNT_ADAPTER)
return adapter
9 changes: 5 additions & 4 deletions anvil_consortium_manager/adapters/workspace.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,11 @@

from abc import ABC, abstractproperty

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.forms import ModelForm
from django.utils.module_loading import import_string

from .. import models
from .. import app_settings, models


class BaseWorkspaceAdapter(ABC):
Expand Down Expand Up @@ -212,11 +211,13 @@ def get_registered_names(self):

def populate_from_settings(self):
"""Populate the workspace adapter registry from settings. Called by AppConfig ready() method."""
adapter_modules = settings.ANVIL_WORKSPACE_ADAPTERS
adapter_modules = app_settings.WORKSPACE_ADAPTERS
print("adapter modules")
print(adapter_modules)
if len(self._registry):
msg = "Registry has already been populated."
raise RuntimeError(msg)
if not len(adapter_modules):
if not adapter_modules:
msg = (
"ANVIL_WORKSPACE_ADAPTERS must specify at least one adapter. Did you mean to use "
"the default `anvil_consortium_manager.adapters.default.DefaultWorkspaceAdapter`?"
Expand Down
5 changes: 3 additions & 2 deletions anvil_consortium_manager/anvil_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,11 @@
import json
import logging

from django.conf import settings
from google.auth.transport.requests import AuthorizedSession
from google.oauth2 import service_account

from . import app_settings

logger = logging.getLogger(__name__)


Expand Down Expand Up @@ -47,7 +48,7 @@ def __init__(self):
This way, all instances should share the same authorized session.
"""
if AnVILAPIClient.auth_session is None:
credentials = service_account.Credentials.from_service_account_file(settings.ANVIL_API_SERVICE_ACCOUNT_FILE)
credentials = service_account.Credentials.from_service_account_file(app_settings.API_SERVICE_ACCOUNT_FILE)
scoped_credentials = credentials.with_scopes(
[
"https://www.googleapis.com/auth/userinfo.profile",
Expand Down
69 changes: 69 additions & 0 deletions anvil_consortium_manager/app_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# App settings.
# Mostly follows django-allauth:
# https://github.com/pennersr/django-allauth/blob/main/allauth/app_settings.py

from django.conf import settings
from django.core.exceptions import ImproperlyConfigured


class AppSettings(object):
"""Class to handle settings for django-anvil-consortium-manager."""

def __init__(self, prefix):
self.prefix = prefix

def _setting(self, name, default=None):
from django.conf import settings

return getattr(settings, self.prefix + name, default)

@property
def API_SERVICE_ACCOUNT_FILE(self):
"""The path to the service account to use for managing access on AnVIL. Required."""
x = self._setting("API_SERVICE_ACCOUNT_FILE")
if not x:
raise ImproperlyConfigured("ANVIL_API_SERVICE_ACCOUNT_FILE is required in settings.py")
return x

@property
def WORKSPACE_ADAPTERS(self):
"""Workspace adapters. Required."""
x = self._setting("WORKSPACE_ADAPTERS")
if not x:
msg = (
"ANVIL_WORKSPACE_ADAPTERS must specify at least one adapter. Did you mean to use "
"the default `anvil_consortium_manager.adapters.default.DefaultWorkspaceAdapter`?"
)
raise ImproperlyConfigured(msg)
return x

@property
def ACCOUNT_LINK_EMAIL_SUBJECT(self):
"""Subject line for AnVIL account verification emails. Default: 'Verify your AnVIL account email'"""
return self._setting("ACCOUNT_LINK_EMAIL_SUBJECT", "Verify your AnVIL account email")

@property
def ACCOUNT_LINK_REDIRECT(self):
"""The URL for AccountLinkVerify view redirect. Default: settings.LOGIN_REDIRECT_URL."""
return self._setting("ACCOUNT_LINK_REDIRECT", settings.LOGIN_REDIRECT_URL)

@property
def ACCOUNT_VERIFY_NOTIFICATION_EMAIL(self):
"""If desired, specify the email address to send an email to after a user verifies an account. Default: None.
Set to None to disable (default).
"""
return self._setting("ACCOUNT_VERIFY_NOTIFICATION_EMAIL", None)

@property
def ACCOUNT_ADAPTER(self):
"""Account adapter. Default: anvil_consortium_manager.adapters.default.DefaultAccountAdapter."""
return self._setting("ACCOUNT_ADAPTER", "anvil_consortium_manager.adapters.default.DefaultAccountAdapter")


_app_settings = AppSettings("ANVIL_")


def __getattr__(name):
# See https://peps.python.org/pep-0562/
return getattr(_app_settings, name)
8 changes: 2 additions & 6 deletions anvil_consortium_manager/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from crispy_forms import layout
from crispy_forms.helper import FormHelper
from dal import autocomplete, forward
from django import VERSION as DJANGO_VERSION
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
Expand Down Expand Up @@ -179,13 +178,10 @@ def clean_billing_project(self):
return billing_project

def clean(self):
# DJANGO <4.1 on mysql:
# Check for the same case insensitive name in the same billing project.
is_mysql = settings.DATABASES["default"]["ENGINE"] == "django.db.backends.mysql"
if is_mysql and DJANGO_VERSION >= (4, 1):
# This is handled by the model full_clean method with case-insensitive collation.
pass
else:
if not is_mysql:
print("here")
billing_project = self.cleaned_data.get("billing_project", None)
name = self.cleaned_data.get("name", None)
if (
Expand Down
12 changes: 4 additions & 8 deletions anvil_consortium_manager/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from django_extensions.db.models import ActivatorModel, TimeStampedModel
from simple_history.models import HistoricalRecords, HistoricForeignKey

from . import exceptions
from . import app_settings, exceptions
from .adapters.workspace import workspace_adapter_registry
from .anvil_api import AnVILAPIClient, AnVILAPIError, AnVILAPIError404
from .tokens import account_verification_token
Expand Down Expand Up @@ -142,7 +142,7 @@ def send_verification_email(self, domain):
Args:
domain (str): The domain of the current site, used to create the link.
"""
mail_subject = settings.ANVIL_ACCOUNT_LINK_EMAIL_SUBJECT
mail_subject = app_settings.ACCOUNT_LINK_EMAIL_SUBJECT
url_subdirectory = "http://{domain}{url}".format(
domain=domain,
url=reverse(
Expand All @@ -161,11 +161,7 @@ def send_verification_email(self, domain):

def send_notification_email(self):
"""Send notification email after account is verified if the email setting is set"""
if (
hasattr(settings, "ANVIL_ACCOUNT_VERIFY_NOTIFICATION_EMAIL")
and settings.ANVIL_ACCOUNT_VERIFY_NOTIFICATION_EMAIL
and not settings.ANVIL_ACCOUNT_VERIFY_NOTIFICATION_EMAIL.isspace()
):
if app_settings.ACCOUNT_VERIFY_NOTIFICATION_EMAIL:
mail_subject = "User verified AnVIL account"
message = render_to_string(
"anvil_consortium_manager/account_notification_email.html",
Expand All @@ -178,7 +174,7 @@ def send_notification_email(self):
mail_subject,
message,
None,
[settings.ANVIL_ACCOUNT_VERIFY_NOTIFICATION_EMAIL],
[app_settings.ACCOUNT_VERIFY_NOTIFICATION_EMAIL],
fail_silently=False,
)

Expand Down
5 changes: 1 addition & 4 deletions anvil_consortium_manager/tests/settings/test.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@

# Since there are no templates for redirects in this app, specify the open URL.
LOGIN_URL = "test_login"
LOGIN_REDIRECT_URL = "test_home"

# Django is switching how forms are handled (divs). Set the FORM_RENDERER temporary setting until
# it is removed in Django 6.0.
Expand All @@ -147,10 +148,6 @@
# Path to the service account to use for managing access.
# Because the calls are mocked, we don't need to set this.
ANVIL_API_SERVICE_ACCOUNT_FILE = "foo"
ANVIL_ACCOUNT_LINK_REDIRECT = "test_home"
ANVIL_ACCOUNT_LINK_EMAIL_SUBJECT = "account activation"

ANVIL_WORKSPACE_ADAPTERS = [
"anvil_consortium_manager.adapters.default.DefaultWorkspaceAdapter",
]
ANVIL_ACCOUNT_ADAPTER = "anvil_consortium_manager.adapters.default.DefaultAccountAdapter"
76 changes: 76 additions & 0 deletions anvil_consortium_manager/tests/test_app_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from django.core.exceptions import ImproperlyConfigured
from django.test import TestCase
from django.test.utils import override_settings

from .. import app_settings


class TestAppSettings(TestCase):
def test_api_service_account_file(self):
# Using test settings.
self.assertEqual(app_settings.API_SERVICE_ACCOUNT_FILE, "foo")

@override_settings(ANVIL_API_SERVICE_ACCOUNT_FILE=None)
def test_api_service_account_file_none(self):
with self.assertRaisesMessage(
ImproperlyConfigured, "ANVIL_API_SERVICE_ACCOUNT_FILE is required in settings.py"
):
app_settings.API_SERVICE_ACCOUNT_FILE

def test_workspace_adapters(self):
# Using test settings.
self.assertEqual(
app_settings.WORKSPACE_ADAPTERS, ["anvil_consortium_manager.adapters.default.DefaultWorkspaceAdapter"]
)

@override_settings(ANVIL_WORKSPACE_ADAPTERS=None)
def test_workspace_adapters_none(self):
with self.assertRaisesMessage(ImproperlyConfigured, "must specify at least one adapter"):
app_settings.WORKSPACE_ADAPTERS

@override_settings(ANVIL_WORKSPACE_ADAPTERS=[])
def test_workspace_adapters_empty_array(self):
with self.assertRaisesMessage(ImproperlyConfigured, "must specify at least one adapter"):
app_settings.WORKSPACE_ADAPTERS

@override_settings(
ANVIL_WORKSPACE_ADAPTERS=[
"anvil_consortium_manager.adapters.default.DefaultWorkspaceAdapter",
"anvil_consortium_manager.tests.test_app.adapters.TestWorkspaceAdapter",
]
)
def test_workspace_adapters_multiple(self):
adapters = app_settings.WORKSPACE_ADAPTERS
self.assertEqual(len(adapters), 2)
self.assertIn("anvil_consortium_manager.adapters.default.DefaultWorkspaceAdapter", adapters)
self.assertIn("anvil_consortium_manager.tests.test_app.adapters.TestWorkspaceAdapter", adapters)

def test_account_link_email_subject(self):
self.assertEqual(app_settings.ACCOUNT_LINK_EMAIL_SUBJECT, "Verify your AnVIL account email")

@override_settings(ANVIL_ACCOUNT_LINK_EMAIL_SUBJECT="account activation")
def test_account_link_email_subject_custom(self):
self.assertEqual(app_settings.ACCOUNT_LINK_EMAIL_SUBJECT, "account activation")

def test_account_link_redirect(self):
self.assertEqual(app_settings.ACCOUNT_LINK_REDIRECT, "test_home")

@override_settings(ANVIL_ACCOUNT_LINK_REDIRECT="test_login")
def test_account_link_redirect_custom(self):
self.assertEqual(app_settings.ACCOUNT_LINK_REDIRECT, "test_login")

def test_account_verify_notification_email(self):
self.assertEqual(app_settings.ACCOUNT_VERIFY_NOTIFICATION_EMAIL, None)

@override_settings(ANVIL_ACCOUNT_VERIFY_NOTIFICATION_EMAIL="[email protected]")
def test_account_verify_notification_email_custom(self):
self.assertEqual(app_settings.ACCOUNT_VERIFY_NOTIFICATION_EMAIL, "[email protected]")

def test_account_adapter(self):
self.assertEqual(
app_settings.ACCOUNT_ADAPTER, "anvil_consortium_manager.adapters.default.DefaultAccountAdapter"
)

@override_settings(ANVIL_ACCOUNT_ADAPTER="anvil_consortium_manager.test_app.adapters.TestAccountAdapter")
def test_account_adapter_custom(self):
self.assertEqual(app_settings.ACCOUNT_ADAPTER, "anvil_consortium_manager.test_app.adapters.TestAccountAdapter")
29 changes: 21 additions & 8 deletions anvil_consortium_manager/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,27 @@ def test_send_verification_email(self):
# One message has been sent.
self.assertEqual(len(mail.outbox), 1)
# The subject is correct.
self.assertEqual(mail.outbox[0].subject, "account activation")
self.assertEqual(mail.outbox[0].subject, "Verify your AnVIL account email")
# The contents are correct.
email_body = mail.outbox[0].body
self.assertIn("http://www.test.com", email_body)
self.assertIn(email_entry.user.username, email_body)
self.assertIn(account_verification_token.make_token(email_entry), email_body)
self.assertIn(str(email_entry.uuid), email_body)

# This test occasionally fails if the time flips one second between sending the email and
# regenerating the token. Use freezegun's freeze_time decorator to fix the time and avoid
# this spurious failure.
@freeze_time("2022-11-22 03:12:34")
@override_settings(ANVIL_ACCOUNT_LINK_EMAIL_SUBJECT="custom subject")
def test_send_verification_email_custom_subject(self):
"""Verification email is correct."""
email_entry = factories.UserEmailEntryFactory.create()
email_entry.send_verification_email("www.test.com")
# One message has been sent.
self.assertEqual(len(mail.outbox), 1)
# The subject is correct.
self.assertEqual(mail.outbox[0].subject, "custom subject")
# The contents are correct.
email_body = mail.outbox[0].body
self.assertIn("http://www.test.com", email_body)
Expand Down Expand Up @@ -201,13 +221,6 @@ def test_send_notification_email_none(self):
email_entry.send_notification_email()
self.assertEqual(len(mail.outbox), 0)

@override_settings(ANVIL_ACCOUNT_VERIFY_NOTIFICATION_EMAIL=" ")
def test_send_notification_email_spaces(self):
"""Notification email is sent if ANVIL_ACCOUNT_VERIFY_NOTIFICATION_EMAIL is set to only spaces."""
email_entry = factories.UserEmailEntryFactory.create()
email_entry.send_notification_email()
self.assertEqual(len(mail.outbox), 0)


class AccountTest(TestCase):
def test_model_saving(self):
Expand Down
Loading

0 comments on commit de6c75b

Please sign in to comment.