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

Feat: benefits command line interface #2496

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
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
16 changes: 14 additions & 2 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"name": "Django: Benefits Client",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/manage.py",
"program": "${workspaceFolder}/benefits/cli/main.py",
"args": ["runserver", "--insecure", "0.0.0.0:8000"],
"django": true,
"env": {
Expand All @@ -20,13 +20,25 @@
"name": "Django: Benefits Client, Debug=False",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/manage.py",
"program": "${workspaceFolder}/benefits/cli/main.py",
"args": ["runserver", "--insecure", "0.0.0.0:8000"],
"django": true,
"env": {
"DJANGO_DEBUG": "false",
"DJANGO_STATICFILES_STORAGE": "django.contrib.staticfiles.storage.StaticFilesStorage"
}
},
{
"name": "Benefits CLI",
"type": "python",
"request": "launch",
"program": "${workspaceFolder}/benefits/cli/main.py",
"args": [],
"django": true,
"env": {
"DJANGO_DEBUG": "true",
"PYTHONWARNINGS": "default"
}
}
]
}
1 change: 0 additions & 1 deletion appcontainer/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ COPY appcontainer/proxy.conf /calitp/run/proxy.conf

# copy runtime files
COPY --from=build_wheel /build/dist /build/dist
COPY manage.py manage.py
COPY bin bin
COPY benefits benefits

Expand Down
Empty file added benefits/cli/__init__.py
Empty file.
Empty file.
114 changes: 114 additions & 0 deletions benefits/cli/agency/create.py
Original file line number Diff line number Diff line change
@@ -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"))
73 changes: 73 additions & 0 deletions benefits/cli/agency/list.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
from dataclasses import dataclass

from django.db.models import Q

from benefits.cli.commands import BaseOptions, BenefitsCommand
from benefits.core.models import TransitAgency


@dataclass
class Options(BaseOptions):
all: bool = False
name: str = None
slug: str = None


class List(BenefitsCommand):
"""List transit agencies."""

help = __doc__
name = "list"
options_cls = Options

def add_arguments(self, parser):
parser.add_argument(
"-a",
"--all",
action="store_true",
default=False,
help="Show both active and inactive agencies. By default show only active agencies.",
)
parser.add_argument(
"-n",
"--name",
type=str,
help="Filter for agencies with matching (partial) short_name or long_name.",
)
parser.add_argument(
"-s",
"--slug",
type=str,
help="Filter for agencies with matching (partial) slug.",
)

def handle(self, *args, **options):
opts = self.parse_opts(**options)
agencies = TransitAgency.objects.all()

if not opts.all:
agencies = agencies.filter(active=True)

if opts.name:
q = Q(short_name__contains=opts.name) | Q(long_name__contains=opts.name)
agencies = agencies.filter(q)

if opts.slug:
agencies = agencies.filter(slug__contains=opts.slug)

if len(agencies) > 0:
if len(agencies) > 1:
msg = f"{len(agencies)} agencies:"
else:
msg = "1 agency:"
self.stdout.write(self.style.SUCCESS(msg))

active = filter(lambda a: a.active, agencies)
inactive = filter(lambda a: not a.active, agencies)

for agency in active:
self.stdout.write(self.style.HTTP_NOT_MODIFIED(f"{agency}"))
for agency in inactive:
self.stdout.write(self.style.WARNING(f"[inactive] {agency}"))
else:
self.stdout.write(self.style.HTTP_NOT_FOUND("No matching agencies"))
11 changes: 11 additions & 0 deletions benefits/cli/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
"""
The cli application: Command line interface for working with the Cal-ITP Benefits app.
"""

from django.apps import AppConfig


class CoreAppConfig(AppConfig):
name = "benefits.cli"
label = "cli"
verbose_name = "Benefits CLI"
127 changes: 127 additions & 0 deletions benefits/cli/commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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


@dataclass
class BaseOptions:
handler: Callable = None
skip_checks: bool = False
verbosity: int = 1


class BenefitsCommand(BaseCommand):
"""Base class for Benefits CLI commands."""

name = "benefits"
help = __doc__
options_cls = BaseOptions
subcommands = []
version = VERSION

def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False, default_subcmd=None):
super().__init__(stdout, stderr, no_color, force_color)

self.commands = set()
# for each subcommand declared on the BenefitsCommand class (or subclass)
# instantiate it and add it to the commands set
self.commands.update(cmd(stdout, stderr, no_color, force_color) for cmd in self.subcommands)
# set an attribute on this instance for each command
for cmd in self.commands:
setattr(self, cmd.name, cmd)
# can be used by subclasses to track their own argument subparsers
self.subparsers = {self.name: {}}

# default_subcmd should be one of the items in BenefitsCommand.subcommands (or subclass.subcommands)
# i.e. a class that implements one of the subcommands for this BenefitsCommand (or subclass)
if default_subcmd:
if default_subcmd in self.subcommands:
# we want to store the handle method for the instance of this default_subcmd class
# read command attribute created above
# the default handler is a function to call when the command is called without a subcommand
self.default_handler = getattr(self, default_subcmd.name).handle
else:
raise ValueError("default_subcmd must be in this Command's declared subcommands list.")
else:
self.default_handler = None

@property
def subparser(self):
"""Gets the single self.subparsers with a name matching this command's."""
return self.subparsers[self.name]

def add_arguments(self, parser):
"""Entry point for subclassed commands to add custom arguments."""
if len(self.commands) < 1:
return

# For each command this BenefitsCommand instance defines
# create an argparse subparser for that command's arguments
# adapted from https://adamj.eu/tech/2024/08/14/django-management-command-sub-commands/
command_required = self.default_handler is None
subparsers = parser.add_subparsers(title="commands", required=command_required)

for command in self.commands:
subparser = subparsers.add_parser(command.name, help=command.help)
# command is an instance inheriting from BenefitsCommand
# so it has an .add_arguments() method
# add them to the command's subparser
command.add_arguments(subparser)
# set_defaults makes the resulting "handler" argument equal to the
# command's handle function
subparser.set_defaults(handler=command.handle)
# store a reference to each command subparser
self.subparser[command.name] = subparser

def get_version(self) -> str:
"""Override `BaseCommand.get_version()` to return the `benefits` version."""
return self.version

def handle(self, *args, **options):
"""The actual logic of the command. Subclasses of Django's `BaseCommand` must implement this method.

The default implementation for `BenefitsCommands` implements a pattern like:

```
command --cmd-opt subcommand --subcmd-opt subcmd_arg
```

Subclasses of `BenefitsCommand` may provide a custom implementation as-needed.
"""
# by default, parse options as a BaseOptions instance
opts = self.parse_opts(**options)
# get the handler parsed from the options, or the default_handler
handler = opts.handler or self.default_handler

if handler:
if opts.verbosity > 1:
# handler is a class method, use its __self__ prop to get back to the instance
command_name = handler.__self__.name
self.stdout.write(self.style.WARNING(f"command: {command_name}"))
# call it
handler(*args, **options)

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}")
21 changes: 21 additions & 0 deletions benefits/cli/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#!/usr/bin/env python
"""Benefits command-line utility for administrative tasks."""
import os
import sys

from django.core.management import execute_from_command_line

from benefits import VERSION


def main():
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "benefits.settings")

if len(sys.argv) == 2 and sys.argv[1] == "--version":
print(f"benefits, {VERSION}")

execute_from_command_line(sys.argv)


if __name__ == "__main__":
main()
Empty file.
Empty file.
Loading
Loading