From a6232578818bb1891ff915fdfd875f82b0d1794e Mon Sep 17 00:00:00 2001 From: Kegan Maher Date: Thu, 31 Oct 2024 02:57:18 +0000 Subject: [PATCH] feat(cli): define the agency create command benefits agency create -h --- benefits/cli/agency/create.py | 114 ++++++++++++++++++ benefits/cli/commands.py | 17 +++ benefits/cli/management/commands/agency.py | 3 +- tests/pytest/cli/agency/test_create.py | 49 ++++++++ tests/pytest/cli/conftest.py | 5 + .../cli/management/commands/test_agency.py | 21 +++- 6 files changed, 207 insertions(+), 2 deletions(-) create mode 100644 benefits/cli/agency/create.py create mode 100644 tests/pytest/cli/agency/test_create.py diff --git a/benefits/cli/agency/create.py b/benefits/cli/agency/create.py new file mode 100644 index 0000000000..e4a7ddca05 --- /dev/null +++ b/benefits/cli/agency/create.py @@ -0,0 +1,114 @@ +from dataclasses import dataclass +from pathlib import Path + +from django.core.management.base import CommandError + +from benefits.cli.commands import BaseOptions, BenefitsCommand +from benefits.core.models import TransitAgency, TransitProcessor + + +@dataclass +class Options(BaseOptions): + active: bool = False + info_url: str = None + long_name: str = None + phone: str = None + short_name: str = None + slug: str = None + templates: bool = False + templates_only: bool = False + transit_processor: int = None + + def __post_init__(self): + if not self.short_name: + self.short_name = self.slug.upper() + if not self.long_name: + self.long_name = self.slug.upper() + + +class Create(BenefitsCommand): + """Create a new transit agency.""" + + help = __doc__ + name = "create" + options_cls = Options + sample_slug = "cst" + templates = [ + f"core/index--{sample_slug}.html", + f"eligibility/index--{sample_slug}.html", + ] + + @property + def template_paths(self): + return [self.template_path(t) for t in self.templates] + + def _create_agency(self, opts: Options) -> TransitAgency: + if isinstance(opts.transit_processor, int): + transit_processor = TransitProcessor.objects.get(id=opts.transit_processor) + else: + transit_processor = None + + agency = TransitAgency.objects.create( + active=opts.active, + slug=opts.slug, + info_url=opts.info_url, + long_name=opts.long_name, + phone=opts.phone, + short_name=opts.short_name, + transit_processor=transit_processor, + ) + agency.save() + + return agency + + def _create_templates(self, agency: TransitAgency): + for template in self.template_paths: + content = template.read_text().replace(self.sample_slug, agency.slug) + content = content.replace(self.sample_slug.upper(), agency.slug.upper()) + + path = str(template.resolve()).replace(self.sample_slug, agency.slug) + + new_template = Path(path) + new_template.write_text(content) + + def _raise_for_slug(self, opts: Options) -> bool: + if TransitAgency.by_slug(opts.slug): + raise CommandError(f"TransitAgency with slug already exists: {opts.slug}") + + def add_arguments(self, parser): + parser.add_argument("-a", "--active", action="store_true", default=False, help="Activate the agency") + parser.add_argument( + "-i", "--info-url", type=str, default="https://agency.com", help="The agency's informational website URL" + ) + parser.add_argument("-l", "--long-name", type=str, default="Regional Transit Agency", help="The agency's long name") + parser.add_argument("-p", "--phone", type=str, default="800-555-5555", help="The agency's phone number") + parser.add_argument("-s", "--short-name", type=str, default="Agency", help="The agency's short name") + parser.add_argument("--templates", action="store_true", default=False, help="Also create templates for the agency") + parser.add_argument( + "--templates-only", + action="store_true", + default=False, + help="Don't create the agency in the database, but scaffold templates", + ) + parser.add_argument( + "--transit-processor", + type=int, + choices=[t.id for t in TransitProcessor.objects.all()], + default=TransitProcessor.objects.first().id, + help="The id of a TransitProcessor instance to link to this agency", + ) + parser.add_argument("slug", help="The agency's slug", type=str) + + def handle(self, *args, **options): + opts = self.parse_opts(**options) + self._raise_for_slug(opts) + + if not opts.templates_only: + self.stdout.write(self.style.NOTICE("Creating new agency...")) + agency = self._create_agency(opts) + self.stdout.write(self.style.SUCCESS(f"Agency created: {agency.slug} (id={agency.id})")) + + if opts.templates: + self.stdout.write(self.style.NOTICE("Creating new agency templates...")) + self._create_templates(agency) + self.stdout.write(self.style.SUCCESS("Templates created")) diff --git a/benefits/cli/commands.py b/benefits/cli/commands.py index 1a8eb472ee..1601547afe 100644 --- a/benefits/cli/commands.py +++ b/benefits/cli/commands.py @@ -1,6 +1,8 @@ from dataclasses import dataclass +from pathlib import Path from typing import Callable +from django import template from django.core.management.base import BaseCommand from benefits import VERSION @@ -108,3 +110,18 @@ def parse_opts(self, **options): """Parse options into a dataclass instance.""" options = {k: v for k, v in options.items() if k in dir(self.options_cls)} return self.options_cls(**options) + + def template_path(self, template_name: str) -> Path: + """Get a `pathlib.Path` for the named template. + + A `template_name` is the app-local name, e.g. `enrollment/success.html`. + + Adapted from https://stackoverflow.com/a/75863472. + """ + for engine in template.engines.all(): + for loader in engine.engine.template_loaders: + for origin in loader.get_template_sources(template_name): + path = Path(origin.name) + if path.exists(): + return path + raise template.TemplateDoesNotExist(f"Could not find template: {template_name}") diff --git a/benefits/cli/management/commands/agency.py b/benefits/cli/management/commands/agency.py index 25196c5401..087151a17b 100644 --- a/benefits/cli/management/commands/agency.py +++ b/benefits/cli/management/commands/agency.py @@ -1,3 +1,4 @@ +from benefits.cli.agency.create import Create from benefits.cli.agency.list import List from benefits.cli.commands import BenefitsCommand @@ -7,7 +8,7 @@ class Command(BenefitsCommand): help = __doc__ name = "agency" - subcommands = [List] + subcommands = [List, Create] def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False): # make List the default_subcmd diff --git a/tests/pytest/cli/agency/test_create.py b/tests/pytest/cli/agency/test_create.py new file mode 100644 index 0000000000..f4d1e7d803 --- /dev/null +++ b/tests/pytest/cli/agency/test_create.py @@ -0,0 +1,49 @@ +import pytest + +from django.core.management.base import CommandError + +from benefits.cli.agency.create import Create +from benefits.core.models import TransitAgency + + +@pytest.fixture +def cmd(cmd): + def call(*args, **kwargs): + return cmd(Create, *args, **kwargs) + + return call + + +@pytest.mark.django_db +def test_call_no_slug(cmd): + with pytest.raises(CommandError, match="the following arguments are required: slug"): + cmd() + + +@pytest.mark.django_db +def test_call(cmd, model_TransitProcessor): + slug = "the-slug" + + agency = TransitAgency.by_slug(slug) + assert agency is None + + out, err = cmd(slug) + + assert err == "" + assert "Creating new agency" in out + assert f"Agency created: {slug}" in out + + agency = TransitAgency.by_slug(slug) + assert isinstance(agency, TransitAgency) + assert agency.transit_processor == model_TransitProcessor + + +@pytest.mark.django_db +def test_call_dupe(cmd): + slug = "the-slug" + + # first time is OK + cmd(slug) + # again with the same slug, not OK + with pytest.raises(CommandError, match=f"TransitAgency with slug already exists: {slug}"): + cmd(slug) diff --git a/tests/pytest/cli/conftest.py b/tests/pytest/cli/conftest.py index 1f7e3b5c4f..d39a09e4fb 100644 --- a/tests/pytest/cli/conftest.py +++ b/tests/pytest/cli/conftest.py @@ -10,3 +10,8 @@ def call(cls, *args, **kwargs): return capfd.readouterr() return call + + +@pytest.fixture(autouse=True) +def db_setup(model_TransitProcessor): + pass diff --git a/tests/pytest/cli/management/commands/test_agency.py b/tests/pytest/cli/management/commands/test_agency.py index 085d7dd5dd..ae9ee45554 100644 --- a/tests/pytest/cli/management/commands/test_agency.py +++ b/tests/pytest/cli/management/commands/test_agency.py @@ -1,14 +1,25 @@ import pytest +from benefits.cli.agency.create import Create from benefits.cli.agency.list import List from benefits.cli.management.commands.agency import Command +@pytest.fixture +def cmd(cmd): + def call(*args, **kwargs): + return cmd(Command, *args, **kwargs) + + return call + + @pytest.mark.django_db def test_class(): assert Command.help == Command.__doc__ assert Command.name == "agency" - assert Command.subcommands == [List] + + assert List in Command.subcommands + assert Create in Command.subcommands @pytest.mark.django_db @@ -21,3 +32,11 @@ def test_init(): list_cmd = getattr(cmd, "list") assert isinstance(list_cmd, List) assert cmd.default_handler == list_cmd.handle + + +@pytest.mark.django_db +def test_call(cmd): + out, err = cmd() + + assert "No matching agencies" in out + assert err == ""