netbox-atlassian 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,73 @@
1
+ """
2
+ NetBox Atlassian Plugin
3
+
4
+ Display Jira issues and Confluence pages related to devices in NetBox.
5
+ Searches by configurable fields (hostname, serial, role, etc.) with OR logic.
6
+ """
7
+
8
+ from netbox.plugins import PluginConfig
9
+
10
+ __version__ = "0.1.0"
11
+
12
+
13
+ class AtlassianConfig(PluginConfig):
14
+ """Plugin configuration for NetBox Atlassian integration."""
15
+
16
+ name = "netbox_atlassian"
17
+ verbose_name = "Atlassian"
18
+ description = "Display Jira issues and Confluence pages related to devices"
19
+ version = __version__
20
+ author = "sieteunoseis"
21
+ author_email = "sieteunoseis@github.com"
22
+ base_url = "atlassian"
23
+ min_version = "4.0.0"
24
+
25
+ # Required settings - plugin won't load without these
26
+ required_settings = []
27
+
28
+ # Default configuration values
29
+ default_settings = {
30
+ # Jira settings (on-prem)
31
+ "jira_url": "", # e.g., "https://jira.example.com"
32
+ "jira_username": "",
33
+ "jira_password": "", # or API token
34
+ "jira_verify_ssl": True,
35
+ # Confluence settings (on-prem)
36
+ "confluence_url": "", # e.g., "https://confluence.example.com"
37
+ "confluence_username": "",
38
+ "confluence_password": "", # or API token
39
+ "confluence_token": "", # Personal Access Token (PAT) - preferred for on-prem
40
+ "confluence_verify_ssl": True,
41
+ # Cloud settings (for future use)
42
+ "use_cloud": False,
43
+ "cloud_api_token": "",
44
+ "cloud_email": "",
45
+ # Search configuration
46
+ # Fields to search - values are device attribute paths
47
+ # Searches use OR logic - matches any field
48
+ "search_fields": [
49
+ {"name": "Hostname", "attribute": "name", "enabled": True},
50
+ {"name": "Serial", "attribute": "serial", "enabled": True},
51
+ {"name": "Asset Tag", "attribute": "asset_tag", "enabled": False},
52
+ {"name": "Role", "attribute": "role.name", "enabled": False},
53
+ {"name": "Primary IP", "attribute": "primary_ip4.address", "enabled": False},
54
+ ],
55
+ # Jira search settings
56
+ "jira_max_results": 10,
57
+ "jira_projects": [], # Empty = search all projects
58
+ "jira_issue_types": [], # Empty = all types
59
+ # Confluence search settings
60
+ "confluence_max_results": 10,
61
+ "confluence_spaces": [], # Empty = search all spaces
62
+ # General settings
63
+ "timeout": 30,
64
+ "cache_timeout": 300, # Cache results for 5 minutes
65
+ # Enable legacy SSL renegotiation for older servers (required for OHSU wiki)
66
+ "enable_legacy_ssl": False,
67
+ # Device type filtering (like catalyst-center)
68
+ # Empty list = show tab for all devices
69
+ "device_types": [], # e.g., ["cisco", "juniper"]
70
+ }
71
+
72
+
73
+ config = AtlassianConfig
@@ -0,0 +1,346 @@
1
+ """
2
+ Atlassian API Client for Jira and Confluence
3
+
4
+ Supports both on-premise and cloud deployments.
5
+ """
6
+
7
+ import logging
8
+ import ssl
9
+ from typing import Optional
10
+
11
+ import requests
12
+ from django.conf import settings
13
+ from django.core.cache import cache
14
+ from requests.adapters import HTTPAdapter
15
+ from urllib3.util.ssl_ import create_urllib3_context
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class LegacySSLAdapter(HTTPAdapter):
21
+ """
22
+ HTTP Adapter that enables legacy SSL renegotiation.
23
+
24
+ Required for older servers (like OHSU Confluence) that don't support
25
+ secure renegotiation.
26
+ """
27
+
28
+ def init_poolmanager(self, *args, **kwargs):
29
+ ctx = create_urllib3_context()
30
+ # Enable legacy renegotiation for older servers
31
+ ctx.options |= ssl.OP_LEGACY_SERVER_CONNECT
32
+ kwargs["ssl_context"] = ctx
33
+ return super().init_poolmanager(*args, **kwargs)
34
+
35
+
36
+ class AtlassianClient:
37
+ """Client for Jira and Confluence REST APIs."""
38
+
39
+ def __init__(self):
40
+ """Initialize the Atlassian client from plugin settings."""
41
+ self.config = settings.PLUGINS_CONFIG.get("netbox_atlassian", {})
42
+
43
+ # Jira settings
44
+ self.jira_url = self.config.get("jira_url", "").rstrip("/")
45
+ self.jira_username = self.config.get("jira_username", "")
46
+ self.jira_password = self.config.get("jira_password", "")
47
+ self.jira_verify_ssl = self.config.get("jira_verify_ssl", True)
48
+
49
+ # Confluence settings
50
+ self.confluence_url = self.config.get("confluence_url", "").rstrip("/")
51
+ self.confluence_username = self.config.get("confluence_username", "")
52
+ self.confluence_password = self.config.get("confluence_password", "")
53
+ self.confluence_token = self.config.get("confluence_token", "") # Personal Access Token
54
+ self.confluence_verify_ssl = self.config.get("confluence_verify_ssl", True)
55
+
56
+ # Cloud settings
57
+ self.use_cloud = self.config.get("use_cloud", False)
58
+ self.cloud_api_token = self.config.get("cloud_api_token", "")
59
+ self.cloud_email = self.config.get("cloud_email", "")
60
+
61
+ # General settings
62
+ self.timeout = self.config.get("timeout", 30)
63
+ self.cache_timeout = self.config.get("cache_timeout", 300)
64
+
65
+ # Legacy SSL support (for servers requiring legacy renegotiation)
66
+ self.enable_legacy_ssl = self.config.get("enable_legacy_ssl", False)
67
+
68
+ # Create session with legacy SSL if needed
69
+ self._session = None
70
+
71
+ def _get_session(self) -> requests.Session:
72
+ """Get or create a requests session with optional legacy SSL support."""
73
+ if self._session is None:
74
+ self._session = requests.Session()
75
+ if self.enable_legacy_ssl:
76
+ adapter = LegacySSLAdapter()
77
+ self._session.mount("https://", adapter)
78
+ return self._session
79
+
80
+ def _get_jira_auth(self):
81
+ """Get authentication for Jira API."""
82
+ if self.use_cloud:
83
+ return (self.cloud_email, self.cloud_api_token)
84
+ return (self.jira_username, self.jira_password)
85
+
86
+ def _get_confluence_auth(self):
87
+ """Get authentication for Confluence API."""
88
+ if self.use_cloud:
89
+ return (self.cloud_email, self.cloud_api_token)
90
+ return (self.confluence_username, self.confluence_password)
91
+
92
+ def _jira_request(self, endpoint: str, params: Optional[dict] = None) -> Optional[dict]:
93
+ """Make a request to Jira REST API."""
94
+ if not self.jira_url:
95
+ return None
96
+
97
+ url = f"{self.jira_url}/rest/api/2/{endpoint}"
98
+ session = self._get_session()
99
+
100
+ try:
101
+ response = session.get(
102
+ url,
103
+ auth=self._get_jira_auth(),
104
+ params=params,
105
+ verify=self.jira_verify_ssl,
106
+ timeout=self.timeout,
107
+ )
108
+ response.raise_for_status()
109
+ return response.json()
110
+ except requests.exceptions.RequestException as e:
111
+ logger.error(f"Jira API error: {e}")
112
+ return None
113
+
114
+ def _confluence_request(self, endpoint: str, params: Optional[dict] = None) -> Optional[dict]:
115
+ """Make a request to Confluence REST API."""
116
+ if not self.confluence_url:
117
+ return None
118
+
119
+ url = f"{self.confluence_url}/rest/api/{endpoint}"
120
+ session = self._get_session()
121
+
122
+ try:
123
+ # Use PAT (Bearer token) if available, otherwise basic auth
124
+ if self.confluence_token:
125
+ headers = {"Authorization": f"Bearer {self.confluence_token}"}
126
+ response = session.get(
127
+ url,
128
+ headers=headers,
129
+ params=params,
130
+ verify=self.confluence_verify_ssl,
131
+ timeout=self.timeout,
132
+ )
133
+ else:
134
+ response = session.get(
135
+ url,
136
+ auth=self._get_confluence_auth(),
137
+ params=params,
138
+ verify=self.confluence_verify_ssl,
139
+ timeout=self.timeout,
140
+ )
141
+ response.raise_for_status()
142
+ return response.json()
143
+ except requests.exceptions.RequestException as e:
144
+ logger.error(f"Confluence API error: {e}")
145
+ return None
146
+
147
+ def search_jira(self, search_terms: list[str], max_results: int = 10) -> dict:
148
+ """
149
+ Search Jira for issues containing any of the search terms.
150
+
151
+ Args:
152
+ search_terms: List of terms to search (OR logic)
153
+ max_results: Maximum number of results to return
154
+
155
+ Returns:
156
+ dict with 'issues' list and 'total' count
157
+ """
158
+ if not self.jira_url or not search_terms:
159
+ return {"issues": [], "total": 0, "error": None}
160
+
161
+ # Build JQL query with OR logic
162
+ # Search in summary, description, and comments
163
+ text_queries = []
164
+ for term in search_terms:
165
+ if term:
166
+ # Escape special JQL characters
167
+ escaped = term.replace('"', '\\"')
168
+ text_queries.append(f'text ~ "{escaped}"')
169
+
170
+ if not text_queries:
171
+ return {"issues": [], "total": 0, "error": None}
172
+
173
+ jql = " OR ".join(text_queries)
174
+
175
+ # Add project filter if configured
176
+ projects = self.config.get("jira_projects", [])
177
+ if projects:
178
+ project_jql = " OR ".join([f'project = "{p}"' for p in projects])
179
+ jql = f"({jql}) AND ({project_jql})"
180
+
181
+ # Add issue type filter if configured
182
+ issue_types = self.config.get("jira_issue_types", [])
183
+ if issue_types:
184
+ type_jql = " OR ".join([f'issuetype = "{t}"' for t in issue_types])
185
+ jql = f"({jql}) AND ({type_jql})"
186
+
187
+ # Order by updated date descending
188
+ jql += " ORDER BY updated DESC"
189
+
190
+ cache_key = f"atlassian_jira_{hash(jql)}"
191
+ cached = cache.get(cache_key)
192
+ if cached:
193
+ cached["cached"] = True
194
+ return cached
195
+
196
+ params = {
197
+ "jql": jql,
198
+ "maxResults": max_results,
199
+ "fields": "summary,status,issuetype,priority,assignee,created,updated,project",
200
+ }
201
+
202
+ result = self._jira_request("search", params)
203
+
204
+ if result is None:
205
+ return {"issues": [], "total": 0, "error": "Failed to connect to Jira"}
206
+
207
+ issues = []
208
+ for issue in result.get("issues", []):
209
+ fields = issue.get("fields", {})
210
+ issues.append({
211
+ "key": issue.get("key"),
212
+ "summary": fields.get("summary", ""),
213
+ "status": fields.get("status", {}).get("name", ""),
214
+ "status_category": fields.get("status", {}).get("statusCategory", {}).get("key", ""),
215
+ "type": fields.get("issuetype", {}).get("name", ""),
216
+ "type_icon": fields.get("issuetype", {}).get("iconUrl", ""),
217
+ "priority": fields.get("priority", {}).get("name", "") if fields.get("priority") else "",
218
+ "priority_icon": fields.get("priority", {}).get("iconUrl", "") if fields.get("priority") else "",
219
+ "assignee": fields.get("assignee", {}).get("displayName", "") if fields.get("assignee") else "Unassigned",
220
+ "created": fields.get("created", ""),
221
+ "updated": fields.get("updated", ""),
222
+ "project": fields.get("project", {}).get("name", ""),
223
+ "project_key": fields.get("project", {}).get("key", ""),
224
+ "url": f"{self.jira_url}/browse/{issue.get('key')}",
225
+ })
226
+
227
+ response = {
228
+ "issues": issues,
229
+ "total": result.get("total", 0),
230
+ "error": None,
231
+ "cached": False,
232
+ }
233
+
234
+ cache.set(cache_key, response, self.cache_timeout)
235
+ return response
236
+
237
+ def search_confluence(self, search_terms: list[str], max_results: int = 10) -> dict:
238
+ """
239
+ Search Confluence for pages containing any of the search terms.
240
+
241
+ Args:
242
+ search_terms: List of terms to search (OR logic)
243
+ max_results: Maximum number of results to return
244
+
245
+ Returns:
246
+ dict with 'pages' list and 'total' count
247
+ """
248
+ if not self.confluence_url or not search_terms:
249
+ return {"pages": [], "total": 0, "error": None}
250
+
251
+ # Build CQL query with OR logic
252
+ text_queries = []
253
+ for term in search_terms:
254
+ if term:
255
+ # Escape special CQL characters
256
+ escaped = term.replace('"', '\\"')
257
+ text_queries.append(f'text ~ "{escaped}"')
258
+
259
+ if not text_queries:
260
+ return {"pages": [], "total": 0, "error": None}
261
+
262
+ cql = " OR ".join(text_queries)
263
+
264
+ # Filter to pages only (not attachments, comments, etc.)
265
+ cql = f"({cql}) AND type = page"
266
+
267
+ # Add space filter if configured
268
+ spaces = self.config.get("confluence_spaces", [])
269
+ if spaces:
270
+ space_cql = " OR ".join([f'space = "{s}"' for s in spaces])
271
+ cql = f"({cql}) AND ({space_cql})"
272
+
273
+ cache_key = f"atlassian_confluence_{hash(cql)}"
274
+ cached = cache.get(cache_key)
275
+ if cached:
276
+ cached["cached"] = True
277
+ return cached
278
+
279
+ params = {
280
+ "cql": cql,
281
+ "limit": max_results,
282
+ "expand": "space,version,ancestors",
283
+ }
284
+
285
+ result = self._confluence_request("content/search", params)
286
+
287
+ if result is None:
288
+ return {"pages": [], "total": 0, "error": "Failed to connect to Confluence"}
289
+
290
+ pages = []
291
+ for page in result.get("results", []):
292
+ space = page.get("space", {})
293
+ version = page.get("version", {})
294
+
295
+ # Build breadcrumb from ancestors
296
+ ancestors = page.get("ancestors", [])
297
+ breadcrumb = " > ".join([a.get("title", "") for a in ancestors])
298
+
299
+ pages.append({
300
+ "id": page.get("id"),
301
+ "title": page.get("title", ""),
302
+ "space_key": space.get("key", ""),
303
+ "space_name": space.get("name", ""),
304
+ "last_modified": version.get("when", ""),
305
+ "last_modified_by": version.get("by", {}).get("displayName", ""),
306
+ "breadcrumb": breadcrumb,
307
+ "url": f"{self.confluence_url}{page.get('_links', {}).get('webui', '')}",
308
+ })
309
+
310
+ response = {
311
+ "pages": pages,
312
+ "total": result.get("totalSize", result.get("size", 0)),
313
+ "error": None,
314
+ "cached": False,
315
+ }
316
+
317
+ cache.set(cache_key, response, self.cache_timeout)
318
+ return response
319
+
320
+ def test_jira_connection(self) -> tuple[bool, str]:
321
+ """Test Jira connection."""
322
+ if not self.jira_url:
323
+ return False, "Jira URL not configured"
324
+
325
+ result = self._jira_request("myself")
326
+ if result:
327
+ return True, f"Connected as {result.get('displayName', result.get('name', 'Unknown'))}"
328
+ return False, "Failed to connect to Jira"
329
+
330
+ def test_confluence_connection(self) -> tuple[bool, str]:
331
+ """Test Confluence connection."""
332
+ if not self.confluence_url:
333
+ return False, "Confluence URL not configured"
334
+
335
+ if not self.confluence_token and not self.confluence_username:
336
+ return False, "Confluence credentials not configured (need token or username/password)"
337
+
338
+ result = self._confluence_request("user/current")
339
+ if result:
340
+ return True, f"Connected as {result.get('displayName', result.get('username', 'Unknown'))}"
341
+ return False, "Failed to connect to Confluence"
342
+
343
+
344
+ def get_client() -> AtlassianClient:
345
+ """Get a configured Atlassian client instance."""
346
+ return AtlassianClient()
@@ -0,0 +1,101 @@
1
+ """
2
+ Forms for NetBox Atlassian Plugin
3
+ """
4
+
5
+ from django import forms
6
+
7
+
8
+ class AtlassianSettingsForm(forms.Form):
9
+ """Form for displaying and validating Atlassian settings."""
10
+
11
+ # Jira settings
12
+ jira_url = forms.URLField(
13
+ required=False,
14
+ label="Jira URL",
15
+ help_text="On-premise Jira server URL (e.g., https://jira.example.com)",
16
+ widget=forms.URLInput(attrs={"class": "form-control", "placeholder": "https://jira.example.com"}),
17
+ )
18
+ jira_username = forms.CharField(
19
+ required=False,
20
+ label="Jira Username",
21
+ help_text="Username for Jira authentication",
22
+ widget=forms.TextInput(attrs={"class": "form-control"}),
23
+ )
24
+ jira_password = forms.CharField(
25
+ required=False,
26
+ label="Jira Password/Token",
27
+ help_text="Password or API token for Jira authentication",
28
+ widget=forms.PasswordInput(attrs={"class": "form-control"}),
29
+ )
30
+ jira_verify_ssl = forms.BooleanField(
31
+ required=False,
32
+ initial=True,
33
+ label="Verify Jira SSL",
34
+ help_text="Verify SSL certificate for Jira connection",
35
+ )
36
+
37
+ # Confluence settings
38
+ confluence_url = forms.URLField(
39
+ required=False,
40
+ label="Confluence URL",
41
+ help_text="On-premise Confluence server URL (e.g., https://confluence.example.com)",
42
+ widget=forms.URLInput(attrs={"class": "form-control", "placeholder": "https://confluence.example.com"}),
43
+ )
44
+ confluence_username = forms.CharField(
45
+ required=False,
46
+ label="Confluence Username",
47
+ help_text="Username for Confluence authentication",
48
+ widget=forms.TextInput(attrs={"class": "form-control"}),
49
+ )
50
+ confluence_password = forms.CharField(
51
+ required=False,
52
+ label="Confluence Password/Token",
53
+ help_text="Password or API token for Confluence authentication",
54
+ widget=forms.PasswordInput(attrs={"class": "form-control"}),
55
+ )
56
+ confluence_verify_ssl = forms.BooleanField(
57
+ required=False,
58
+ initial=True,
59
+ label="Verify Confluence SSL",
60
+ help_text="Verify SSL certificate for Confluence connection",
61
+ )
62
+
63
+ # Search settings
64
+ jira_max_results = forms.IntegerField(
65
+ required=False,
66
+ initial=10,
67
+ min_value=1,
68
+ max_value=100,
69
+ label="Jira Max Results",
70
+ help_text="Maximum number of Jira issues to display",
71
+ widget=forms.NumberInput(attrs={"class": "form-control"}),
72
+ )
73
+ confluence_max_results = forms.IntegerField(
74
+ required=False,
75
+ initial=10,
76
+ min_value=1,
77
+ max_value=100,
78
+ label="Confluence Max Results",
79
+ help_text="Maximum number of Confluence pages to display",
80
+ widget=forms.NumberInput(attrs={"class": "form-control"}),
81
+ )
82
+
83
+ # General settings
84
+ timeout = forms.IntegerField(
85
+ required=False,
86
+ initial=30,
87
+ min_value=5,
88
+ max_value=120,
89
+ label="Timeout",
90
+ help_text="API timeout in seconds",
91
+ widget=forms.NumberInput(attrs={"class": "form-control"}),
92
+ )
93
+ cache_timeout = forms.IntegerField(
94
+ required=False,
95
+ initial=300,
96
+ min_value=0,
97
+ max_value=3600,
98
+ label="Cache Timeout",
99
+ help_text="Cache timeout in seconds (0 to disable caching)",
100
+ widget=forms.NumberInput(attrs={"class": "form-control"}),
101
+ )
@@ -0,0 +1,22 @@
1
+ """
2
+ Navigation menu items for NetBox Atlassian Plugin
3
+ """
4
+
5
+ from netbox.plugins import PluginMenu, PluginMenuItem
6
+
7
+ menu = PluginMenu(
8
+ label="Atlassian",
9
+ groups=(
10
+ (
11
+ "Settings",
12
+ (
13
+ PluginMenuItem(
14
+ link="plugins:netbox_atlassian:settings",
15
+ link_text="Configuration",
16
+ permissions=["dcim.view_device"],
17
+ ),
18
+ ),
19
+ ),
20
+ ),
21
+ icon_class="mdi mdi-jira",
22
+ )