Skip to content

Commit 1f15548

Browse files
authored
Fix credential in admin (#1245)
* feat(admin): add UUID formatting and credential key display in Flask-Admin - Add custom UUID type formatter to display UUIDs as hex strings - Add ProjectCredentialInlineForm for inline credential editing with readonly key - Add ProjectCredentialAdmin with readonly key field and detail view - Auto-generate UUID key when creating new credentials - Use form_widget_args for proper readonly styling * test(admin): add comprehensive test suite for Flask-Admin customizations - Add tests for UUID formatter function - Add tests for ProjectCredentialInlineForm configuration and behavior - Add tests for ProjectCredentialAdmin configuration and key generation - Add tests for JazzbandModelView access control - Add tests for JazzbandAdminIndexView authentication - Add tests for init_app function - Achieve 100% test coverage for admin.py * docs(agents): update testing guidelines to prefer mocker.patch - Replace patch context managers with mocker.patch() in all examples - Add mocker.patch() and monkeypatch to recommended conventions - Add with patch() context managers to 'Don't Do This' section - Update complete example to use mocker.patch() pattern
1 parent 25eb362 commit 1f15548

3 files changed

Lines changed: 424 additions & 68 deletions

File tree

AGENTS.md

Lines changed: 81 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,12 @@ This document provides guidelines for AI agents working on the Jazzband.co codeb
99
This project uses **pytest** with the following conventions:
1010

1111
-**Use pytest-mock's `mocker` fixture** - Available automatically via `pytest-mock` plugin
12+
-**Use `mocker.patch()` for patching** - Automatically cleaned up after each test
13+
-**Use `monkeypatch` fixture for simple attribute/env changes** - When full mocking isn't needed
1214
-**Function-based tests** - Not class-based test methods
1315
-**Never import `unittest.mock` or `mock`** - Use `mocker` fixture instead
14-
-**No `@patch` decorators** - Use `patch()` as context managers instead
16+
-**No `@patch` decorators** - Use `mocker.patch()` instead
17+
-**No `with patch()` context managers** - Use `mocker.patch()` instead
1518

1619
### Test Structure
1720

@@ -21,10 +24,10 @@ def test_function_name(fixture1, fixture2, mocker):
2124
# Arrange - Set up test data and mocks
2225
mock_obj = mocker.MagicMock()
2326
mock_obj.method.return_value = expected_value
27+
mock_something = mocker.patch("module.path.to.object")
2428

2529
# Act - Call the function under test
26-
with patch("module.path.to.object") as mock_something:
27-
result = function_under_test(args)
30+
result = function_under_test(args)
2831

2932
# Assert - Verify the results
3033
assert result == expected_value
@@ -62,34 +65,35 @@ def test_with_database_query(mocker):
6265
mock_user = mocker.MagicMock()
6366
mock_user.login = "test-user"
6467

65-
with patch("module.path.User") as mock_user_class:
66-
mock_user_class.query.get.return_value = mock_user
68+
mock_user_class = mocker.patch("module.path.User")
69+
mock_user_class.query.get.return_value = mock_user
6770

68-
# Your test code here
69-
result = function_that_queries_user(user_id=123)
71+
# Your test code here
72+
result = function_that_queries_user(user_id=123)
7073

71-
# Verify the query was called
72-
mock_user_class.query.get.assert_called_once_with(123)
74+
# Verify the query was called
75+
mock_user_class.query.get.assert_called_once_with(123)
7376
```
7477

7578
#### Mocking GitHub API Calls
7679

7780
```python
7881
def test_github_api_call(mocker, test_app_context):
7982
"""Test function that calls GitHub API."""
80-
with patch("jazzband.projects.tasks.github") as mock_github:
81-
# Setup mock response
82-
mock_response = mocker.MagicMock()
83-
mock_response.status_code = 200
84-
mock_response.json.return_value = {"success": True}
85-
mock_github.some_method.return_value = mock_response
86-
87-
# Call function
88-
result = function_that_uses_github_api()
89-
90-
# Verify
91-
mock_github.some_method.assert_called_once()
92-
assert result is not None
83+
mock_github = mocker.patch("jazzband.projects.tasks.github")
84+
85+
# Setup mock response
86+
mock_response = mocker.MagicMock()
87+
mock_response.status_code = 200
88+
mock_response.json.return_value = {"success": True}
89+
mock_github.some_method.return_value = mock_response
90+
91+
# Call function
92+
result = function_that_uses_github_api()
93+
94+
# Verify
95+
mock_github.some_method.assert_called_once()
96+
assert result is not None
9397
```
9498

9599
#### Mocking Multiple Related Objects
@@ -105,13 +109,13 @@ def test_with_multiple_mocks(mocker):
105109
mock_project.team_slug = "test-project"
106110
mock_project.leads_team_slug = "test-project-leads"
107111

108-
# Patch multiple classes
109-
with patch("module.User") as mock_user_class:
110-
with patch("module.Project") as mock_project_class:
111-
mock_user_class.query.get.return_value = mock_user
112-
mock_project_class.query.get.return_value = mock_project
112+
# Patch multiple classes using mocker.patch
113+
mock_user_class = mocker.patch("module.User")
114+
mock_project_class = mocker.patch("module.Project")
115+
mock_user_class.query.get.return_value = mock_user
116+
mock_project_class.query.get.return_value = mock_project
113117

114-
# Test code here
118+
# Test code here
115119
```
116120

117121
### Testing GitHub API Methods
@@ -157,23 +161,24 @@ def test_task_function(mocker, test_app_context):
157161
mock_project = mocker.MagicMock()
158162
mock_project.name = "test-project"
159163

160-
# Patch database and GitHub
161-
with patch("jazzband.projects.tasks.User") as mock_user_class:
162-
with patch("jazzband.projects.tasks.Project") as mock_project_class:
163-
with patch("jazzband.projects.tasks.github") as mock_github:
164-
# Setup returns
165-
mock_user_class.query.get.return_value = mock_user
166-
mock_project_class.query.get.return_value = mock_project
164+
# Patch database and GitHub using mocker.patch
165+
mock_user_class = mocker.patch("jazzband.projects.tasks.User")
166+
mock_project_class = mocker.patch("jazzband.projects.tasks.Project")
167+
mock_github = mocker.patch("jazzband.projects.tasks.github")
167168

168-
mock_response = mocker.MagicMock()
169-
mock_response.status_code = 200
170-
mock_github.some_api_call.return_value = mock_response
169+
# Setup returns
170+
mock_user_class.query.get.return_value = mock_user
171+
mock_project_class.query.get.return_value = mock_project
171172

172-
# Call the task
173-
task_function(user_id, project_id)
173+
mock_response = mocker.MagicMock()
174+
mock_response.status_code = 200
175+
mock_github.some_api_call.return_value = mock_response
176+
177+
# Call the task
178+
task_function(user_id, project_id)
174179

175-
# Verify interactions
176-
mock_github.some_api_call.assert_called_once()
180+
# Verify interactions
181+
mock_github.some_api_call.assert_called_once()
177182
```
178183

179184
### Assertion Patterns
@@ -263,26 +268,27 @@ def test_add_user_to_team_lead_member(mocker, test_app_context):
263268
mock_project.team_slug = "test-project"
264269
mock_project.leads_team_slug = "test-project-leads"
265270

266-
# Mock User.query.get and Project.query.get
267-
with patch("jazzband.projects.tasks.User") as mock_user_class:
268-
with patch("jazzband.projects.tasks.Project") as mock_project_class:
269-
with patch("jazzband.projects.tasks.github") as mock_github:
270-
mock_user_class.query.get.return_value = mock_user
271-
mock_project_class.query.get.return_value = mock_project
272-
273-
# Mock successful responses
274-
mock_response = mocker.MagicMock()
275-
mock_response.status_code = 200
276-
mock_github.join_team.return_value = mock_response
277-
278-
# Call the task
279-
add_user_to_team(user_id, project_id, is_lead)
280-
281-
# Verify join_team was called twice (main team and leads team)
282-
assert mock_github.join_team.call_count == 2
283-
calls = mock_github.join_team.call_args_list
284-
assert calls[0][0] == ("test-project", "test-lead")
285-
assert calls[1][0] == ("test-project-leads", "test-lead")
271+
# Mock User.query.get and Project.query.get using mocker.patch
272+
mock_user_class = mocker.patch("jazzband.projects.tasks.User")
273+
mock_project_class = mocker.patch("jazzband.projects.tasks.Project")
274+
mock_github = mocker.patch("jazzband.projects.tasks.github")
275+
276+
mock_user_class.query.get.return_value = mock_user
277+
mock_project_class.query.get.return_value = mock_project
278+
279+
# Mock successful responses
280+
mock_response = mocker.MagicMock()
281+
mock_response.status_code = 200
282+
mock_github.join_team.return_value = mock_response
283+
284+
# Call the task
285+
add_user_to_team(user_id, project_id, is_lead)
286+
287+
# Verify join_team was called twice (main team and leads team)
288+
assert mock_github.join_team.call_count == 2
289+
calls = mock_github.join_team.call_args_list
290+
assert calls[0][0] == ("test-project", "test-lead")
291+
assert calls[1][0] == ("test-project-leads", "test-lead")
286292
```
287293

288294
## Code Style
@@ -476,6 +482,11 @@ from unittest.mock import Mock, patch
476482
def test_function(mock_something):
477483
pass
478484

485+
# Using with patch() context managers
486+
def test_function():
487+
with patch("module.something") as mock_something:
488+
pass
489+
479490
# Class-based tests
480491
class TestSomething:
481492
def test_method(self):
@@ -485,14 +496,18 @@ class TestSomething:
485496
### ✅ Do This Instead
486497

487498
```python
488-
# Use mocker fixture
499+
# Use mocker fixture for mocks
489500
def test_function(mocker):
490501
mock_obj = mocker.MagicMock()
491502

492-
# Use patch as context manager
503+
# Use mocker.patch() for patching
493504
def test_function(mocker):
494-
with patch("module.something") as mock_something:
495-
pass
505+
mock_something = mocker.patch("module.something")
506+
507+
# Use monkeypatch for simple attribute changes
508+
def test_function(monkeypatch):
509+
monkeypatch.setattr("module.CONSTANT", "new_value")
510+
monkeypatch.setenv("ENV_VAR", "test_value")
496511

497512
# Function-based tests
498513
def test_something(mocker):

jazzband/admin.py

Lines changed: 71 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
from uuid import UUID, uuid4
2+
13
from flask import redirect, request, session, url_for
24
from flask_admin import Admin, AdminIndexView, expose
35
from flask_admin.contrib import sqla
6+
from flask_admin.model import typefmt
7+
from flask_admin.model.form import InlineFormAdmin
48
from flask_login import current_user
9+
from wtforms import StringField
510

611
from .account.models import OAuth
712
from .auth import current_user_is_roadie
@@ -15,7 +20,20 @@
1520
)
1621

1722

23+
# Custom type formatter for UUID - displays as hex string
24+
def uuid_formatter(view, value, name):
25+
return value.hex if value else ""
26+
27+
28+
# Create custom type formatters dict including UUID
29+
CUSTOM_FORMATTERS = dict(typefmt.BASE_FORMATTERS)
30+
CUSTOM_FORMATTERS[UUID] = uuid_formatter
31+
32+
1833
class JazzbandModelView(sqla.ModelView):
34+
# Apply UUID formatter to all model views
35+
column_type_formatters = CUSTOM_FORMATTERS
36+
1937
def is_accessible(self):
2038
return current_user_is_roadie()
2139

@@ -69,11 +87,38 @@ class EmailAddressAdmin(JazzbandModelView):
6987
column_filters = ("verified", "primary")
7088

7189

90+
class ProjectCredentialInlineForm(InlineFormAdmin):
91+
"""Custom inline form for ProjectCredential that displays key as read-only."""
92+
93+
form_columns = ("id", "is_active", "key")
94+
95+
# Add key as a read-only string field
96+
form_extra_fields = {"key": StringField("Key")}
97+
98+
form_widget_args = {
99+
"key": {"readonly": True, "class": "form-control-plaintext"},
100+
}
101+
102+
def on_model_change(self, form, model):
103+
"""Ensure key is generated for new credentials."""
104+
if not model.key:
105+
model.key = uuid4()
106+
107+
def postprocess_form(self, form_class):
108+
"""Post-process the inline form to populate key field."""
109+
# The key field will be populated from the model data automatically
110+
return form_class
111+
112+
72113
class ProjectAdmin(JazzbandModelView):
73114
column_searchable_list = ("name", "description")
74115
column_filters = ("is_active", "created_at", "updated_at", "pushed_at")
75116

76-
inline_models = [ProjectCredential, ProjectUpload, ProjectMembership]
117+
inline_models = [
118+
ProjectCredentialInlineForm(ProjectCredential),
119+
ProjectUpload,
120+
ProjectMembership,
121+
]
77122

78123

79124
class ProjectUploadAdmin(JazzbandModelView):
@@ -86,6 +131,30 @@ class ProjectMembershipAdmin(JazzbandModelView):
86131
column_searchable_list = ("project_id", "user_id")
87132

88133

134+
class ProjectCredentialAdmin(JazzbandModelView):
135+
column_list = ("id", "project", "is_active", "key")
136+
column_filters = ("is_active", "project_id")
137+
form_columns = ("project", "is_active", "key")
138+
139+
# Enable detail view so users can see the full key
140+
can_view_details = True
141+
column_details_list = ("id", "project", "is_active", "key")
142+
143+
# Add key as a read-only string field in forms
144+
form_extra_fields = {
145+
"key": StringField("Key", description="Auto-generated (read-only)")
146+
}
147+
148+
form_widget_args = {
149+
"key": {"readonly": True, "class": "form-control-plaintext"},
150+
}
151+
152+
def on_model_change(self, form, model, is_created):
153+
"""Ensure key is generated for new credentials."""
154+
if is_created and not model.key:
155+
model.key = uuid4()
156+
157+
89158
def init_app(app):
90159
admin = Admin(
91160
app,
@@ -100,7 +169,7 @@ def init_app(app):
100169
(Project, ProjectAdmin),
101170
(ProjectMembership, ProjectMembershipAdmin),
102171
(ProjectUpload, ProjectUploadAdmin),
103-
(ProjectCredential, JazzbandModelView),
172+
(ProjectCredential, ProjectCredentialAdmin),
104173
]
105174

106175
for model_cls, admin_cls in model_admins:

0 commit comments

Comments
 (0)