Skip to content

Commit 0140edb

Browse files
Adding help_text_check application (ansible#631)
Creates a help check management command which will search the ORM for models whose fields are missing the help_text attribute.
1 parent d013e48 commit 0140edb

File tree

9 files changed

+341
-0
lines changed

9 files changed

+341
-0
lines changed

ansible_base/help_text_check/__init__.py

Whitespace-only changes.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from django.apps import AppConfig
2+
3+
4+
class HelpTextCheckConfig(AppConfig):
5+
default_auto_field = 'django.db.models.BigAutoField'
6+
name = 'ansible_base.help_text_check'
7+
label = 'dab_help_text_check'
8+
verbose_name = 'Django Model Help Text Checker'

ansible_base/help_text_check/management/__init__.py

Whitespace-only changes.

ansible_base/help_text_check/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
from sys import exit
2+
3+
from django.apps import apps
4+
from django.core.management.base import BaseCommand
5+
6+
7+
class Command(BaseCommand):
8+
help = "Ensure models have help_text fields"
9+
ignore_reasons = {}
10+
global_ignore_fields = ['id']
11+
indentation = " "
12+
13+
def add_arguments(self, parser):
14+
parser.add_argument(
15+
"--applications",
16+
type=str,
17+
help="Comma delimited list of the django application to check. If not specified all applications will be checked",
18+
required=False,
19+
)
20+
parser.add_argument("--ignore-file", type=str, help="The path to a file containing entries like: app.model.field to ignore", required=False)
21+
parser.add_argument("--skip-global-ignore", action="store_true", help="Don't ignore the global ignore fields", required=False)
22+
23+
def get_models(self, applications):
24+
installed_applications = apps.app_configs.keys()
25+
models = []
26+
for requested_application in applications.split(','):
27+
found_app = False
28+
for installed_application in installed_applications:
29+
if requested_application in installed_application:
30+
found_app = True
31+
for model in apps.get_app_config(installed_application).get_models():
32+
if model not in models:
33+
models.append(model)
34+
if not found_app:
35+
self.stderr.write(self.style.WARNING(f"Specified application {requested_application} is not in INSTALLED_APPS"))
36+
return models
37+
38+
def handle(self, *args, **options):
39+
ignore_file = options.get('ignore_file', None)
40+
if ignore_file:
41+
try:
42+
with open(ignore_file, 'r') as f:
43+
for line in f.readlines():
44+
elements = line.strip().split('#', 2)
45+
line = elements[0].strip()
46+
if line:
47+
self.ignore_reasons[line] = elements[1] if len(elements) == 2 else 'Not specified'
48+
except FileNotFoundError:
49+
self.stderr.write(self.style.ERROR(f"Ignore file {ignore_file} does not exist"))
50+
exit(255)
51+
except PermissionError:
52+
self.stderr.write(self.style.ERROR(f"No permission to read {ignore_file}"))
53+
exit(255)
54+
except Exception as e:
55+
self.stderr.write(self.style.ERROR(f"Failed to read {ignore_file}: {e}"))
56+
exit(255)
57+
58+
if len(self.ignore_reasons) > 0:
59+
self.stdout.write(f"Ignoring {len(self.ignore_reasons)} field(s):")
60+
for field in self.ignore_reasons.keys():
61+
self.stdout.write(f"{self.indentation}- {field}")
62+
print("")
63+
64+
applications = options.get('applications', None)
65+
if applications:
66+
models = self.get_models(applications)
67+
else:
68+
models = apps.get_models()
69+
70+
scanned_models = 0
71+
return_code = 0
72+
results = {}
73+
for model in models:
74+
scanned_models = scanned_models + 1
75+
76+
model_name = f"{model._meta.app_label}.{model.__name__}"
77+
results[model_name] = {}
78+
for field in model._meta.concrete_fields:
79+
field_name = f"{model_name}.{field.name}"
80+
81+
help_text = getattr(field, 'help_text', '')
82+
if field_name in self.ignore_reasons:
83+
message = self.style.WARNING(f"{self.indentation}{field.name}: {self.ignore_reasons[field_name]}")
84+
elif field.name in self.global_ignore_fields and not options.get('skip_global_ignore', False):
85+
message = self.style.WARNING(f"{self.indentation}{field.name}: global ignore field")
86+
elif not help_text:
87+
return_code = 1
88+
message = self.style.MIGRATE_HEADING(f"{self.indentation}{field.name}: ") + self.style.ERROR("missing help_text")
89+
else:
90+
message = self.style.SUCCESS(f"{self.indentation}{field.name}") + f": {help_text}"
91+
92+
results[model_name][field.name] = message
93+
self.stdout.write(f"Scanned: {scanned_models} model(s)")
94+
95+
for model_name in sorted(results.keys()):
96+
self.stdout.write(self.style.SQL_TABLE(model_name))
97+
for field_name in sorted(results[model_name].keys()):
98+
self.stdout.write(results[model_name][field_name])
99+
self.stdout.write("")
100+
101+
exit(return_code)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
api_version_urls = []
2+
api_urls = []

docs/apps/help_text_check.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Help Text Checker
2+
3+
A simple application to provide a management command which can inspect django models to see if all fields have help_text related to them.
4+
5+
## Settings
6+
7+
Add `ansible_base.help_text_check` to your installed apps:
8+
9+
```
10+
INSTALLED_APPS = [
11+
...
12+
'ansible_base.help_text_check',
13+
]
14+
```
15+
16+
### Additional Settings
17+
18+
There are no additional settings required.
19+
20+
## URLS
21+
22+
This feature does not require any URLs.
23+
24+
## Using the management command
25+
26+
The management command can be run on its own as:
27+
28+
```
29+
manage.py help_text_check
30+
```
31+
32+
By default this will report on all models the ORM knows about.
33+
34+
### Restricting which applications are searched
35+
36+
If you would like to restrict which models will be queried you can do so on a per-application basis by passing in a comma separated value like:
37+
38+
```
39+
manage.py help_text_check --applications=<application1>,<application2>,...
40+
```
41+
42+
Note, each entry in the passed applications is compared to the installed applications and if an installed application name contains an entry specified in applications it will be added to the list of applications to check.
43+
44+
For example, DAB has a number of applications. These can all be tested with the following:
45+
46+
```
47+
manage.py help_text_check --applications=dab
48+
```
49+
50+
This is because the name of all applications in DAB start with `dab_`. If you only wanted to test a single application in DAB you do that like:
51+
52+
```
53+
manage.py help_text_check --application=dab_authentication
54+
```
55+
56+
### Ignoring specific fields
57+
58+
If there are specific fields you want to ignore on a model you can create an "ignore file" where each line in the file is in the syntax of:
59+
```
60+
application.model.field_name
61+
```
62+
63+
Once the file is created you can pass that as the `--ignore-file` parameter like:
64+
```
65+
manage.py help_text_check --ignore-file=<path to file>
66+
```
67+
68+
### Global ignore
69+
70+
The `id` field of all models is ignored by default
71+
72+
If you want to report on the globally ignored fields you can pass in `--skip-global-ignore`
73+
74+
### Return codes
75+
76+
This script returns 3 possible return codes
77+
0 - everything is fine
78+
1 - One or more field is missing help_text
79+
255 - The ignore file was unable to be read for some reason (see output)

test_app/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
'django_extensions',
7474
'debug_toolbar',
7575
'ansible_base.activitystream',
76+
'ansible_base.help_text_check',
7677
]
7778

7879
MIDDLEWARE = [
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
from io import StringIO
2+
from unittest import mock
3+
4+
import pytest
5+
from django.core.management import call_command
6+
from django.db.models import CharField, Model
7+
8+
9+
@pytest.mark.parametrize(
10+
'exception_type,message',
11+
[
12+
(FileNotFoundError, "does not exist"),
13+
(PermissionError, "No permission to read"),
14+
(IndexError, "Failed to read"),
15+
],
16+
)
17+
def test_exception_on_ignore_file_read(exception_type, message):
18+
out = StringIO()
19+
err = StringIO()
20+
21+
with mock.patch("builtins.open", mock.mock_open()) as mock_file:
22+
mock_file.side_effect = exception_type('Testing perm error')
23+
with pytest.raises(SystemExit) as pytest_wrapped_e:
24+
call_command('help_text_check', ignore_file='junk.dne', stdout=out, stderr=err)
25+
26+
assert pytest_wrapped_e.value.code == 255
27+
assert message in err.getvalue()
28+
29+
30+
@pytest.mark.parametrize(
31+
"read_data,has_message",
32+
[
33+
('', False),
34+
('asdf', True),
35+
],
36+
)
37+
def test_valid_exception_types(read_data, has_message):
38+
out = StringIO()
39+
err = StringIO()
40+
41+
with mock.patch('ansible_base.help_text_check.management.commands.help_text_check.apps.get_models', return_value=[]):
42+
with mock.patch("builtins.open", mock.mock_open(read_data=read_data)):
43+
with pytest.raises(SystemExit) as pytest_wrapped_e:
44+
call_command('help_text_check', ignore_file='junk.dne', stdout=out, stderr=err)
45+
46+
assert pytest_wrapped_e.value.code == 0
47+
if has_message:
48+
assert 'Ignoring 1 field(s)' in out.getvalue()
49+
else:
50+
assert 'Ignoring' not in out.getvalue()
51+
52+
53+
def test_missing_application():
54+
out = StringIO()
55+
err = StringIO()
56+
57+
with pytest.raises(SystemExit) as pytest_wrapped_e:
58+
call_command('help_text_check', applications='App3', stdout=out, stderr=err)
59+
60+
assert pytest_wrapped_e.value.code == 0
61+
assert 'is not in INSTALLED_APPS' in err.getvalue()
62+
63+
64+
def get_app_config_mock(app_name):
65+
class mock_app_config:
66+
def __init__(self, app_name):
67+
self.app_name = app_name
68+
69+
def get_models(self):
70+
if self.app_name == 'App1':
71+
return ['App1.model1', 'App1.model2', 'App1.model1']
72+
elif self.app_name == 'App2':
73+
return ['App2.model1']
74+
else:
75+
raise Exception("This has to be called with either App1 or App2")
76+
77+
return mock_app_config(app_name)
78+
79+
80+
def test_app_limit():
81+
from ansible_base.help_text_check.management.commands.help_text_check import Command
82+
83+
command = Command()
84+
85+
with mock.patch.dict('ansible_base.help_text_check.management.commands.help_text_check.apps.app_configs', {'App1': [], 'App2': [], 'App3': []}):
86+
with mock.patch('ansible_base.help_text_check.management.commands.help_text_check.apps.get_app_config') as get_app_config:
87+
get_app_config.side_effect = get_app_config_mock
88+
models = command.get_models('App1,App2')
89+
assert models == ['App1.model1', 'App1.model2', 'App2.model1']
90+
91+
92+
class GoodModel(Model):
93+
class Meta:
94+
app_label = 'Testing'
95+
96+
test_field = CharField(
97+
help_text='Testing help_text',
98+
)
99+
100+
101+
class BadModel(Model):
102+
class Meta:
103+
app_label = 'Testing'
104+
105+
test_field = CharField()
106+
107+
108+
def get_app_config_actual_models(app_name):
109+
class mock_app_config:
110+
def __init__(self, app_name):
111+
self.app_name = app_name
112+
113+
def get_models(self):
114+
if app_name == 'good':
115+
return [GoodModel]
116+
elif app_name == 'bad':
117+
return [BadModel]
118+
else:
119+
return [GoodModel, BadModel]
120+
121+
return mock_app_config(app_name)
122+
123+
124+
@pytest.mark.parametrize(
125+
'test_type',
126+
[
127+
"good",
128+
"bad",
129+
],
130+
)
131+
def test_models(test_type):
132+
out = StringIO()
133+
err = StringIO()
134+
135+
with mock.patch.dict('ansible_base.help_text_check.management.commands.help_text_check.apps.app_configs', {test_type: []}):
136+
with mock.patch('ansible_base.help_text_check.management.commands.help_text_check.apps.get_app_config') as get_app_config:
137+
get_app_config.side_effect = get_app_config_actual_models
138+
with pytest.raises(SystemExit) as pytest_wrapped_e:
139+
call_command('help_text_check', applications=test_type, stdout=out, stderr=err)
140+
141+
if test_type == 'good':
142+
assert pytest_wrapped_e.value.code == 0
143+
assert 'Testing.GoodModel' in out.getvalue()
144+
assert 'Testing help_text' in out.getvalue()
145+
elif test_type == 'bad':
146+
assert pytest_wrapped_e.value.code == 1
147+
assert 'Testing.BadModel' in out.getvalue()
148+
assert 'test_field: missing help_text' in out.getvalue()
149+
else:
150+
assert False, "This test can only do good and bad models right now"

0 commit comments

Comments
 (0)