diff --git a/django_atlassian/addon.py b/django_atlassian/addon.py index 9135f87..f26754e 100644 --- a/django_atlassian/addon.py +++ b/django_atlassian/addon.py @@ -10,6 +10,7 @@ def register(self, host, username, password, url): # reqObject.body = JSON.stringify({pluginUri: descriptorUrl}); # reqObject.jar = false; # request.post(reqObject, + pass - def unregister(self, host, username, password, url): + pass diff --git a/django_atlassian/apps.py b/django_atlassian/apps.py index 3e34ee4..3bfecd1 100644 --- a/django_atlassian/apps.py +++ b/django_atlassian/apps.py @@ -7,3 +7,5 @@ class DjangoAtlassianConfig(AppConfig): name = 'django_atlassian' + def ready(self): + pass \ No newline at end of file diff --git a/django_atlassian/backends/common/base.py b/django_atlassian/backends/common/base.py index 8397d1d..f40c519 100644 --- a/django_atlassian/backends/common/base.py +++ b/django_atlassian/backends/common/base.py @@ -98,12 +98,12 @@ def delete_request(self, part): r = requests.delete(uri, auth=(self.user, self.password)) elif self.sc: token = self.sc.create_token('DELETE', uri) - headers.update({'Authorization': 'JWT {}'.format(token)}) - r = requests.delete(uri) + r = requests.delete(uri, headers={'Authorization': 'JWT {}'.format(token)}) else: return None return r + class AtlassianDatabase(object): Error = requests.exceptions.RequestException @@ -190,7 +190,7 @@ def from_native(self, data, field): elif field[1] in ['user', 'issuetype'] and data is not None: return data['name'] else: - if field[12] and data.has_key('value'): + if field[12] and 'value' in data: return data['value'] return data except Exception as err: @@ -250,18 +250,18 @@ def execute(self, sql, params=None): else: new_params.extend([i]) - if not opts.has_key ('start_at'): + if 'start_at' not in opts: opts['start_at'] = 0 - if not opts.has_key ('max_results'): + if 'max_results' not in opts: opts['max_results'] = -1 - if not opts.has_key ('count_only'): + if 'count_only' not in opts: opts['count_only'] = False self.opts = opts self.sql = self.get_sql_qs(sql) - self.sql = self.sql % tuple(self.escape_type(p) for p in new_params) + self.sql = self.sql % tuple(p for p in new_params) # Only include the requested fields - if opts.has_key('fields'): + if 'fields' in opts: # The fields have the name of the 'clause' used, but we need to use # the 'id' to tell the API what fields to expand fields_s = opts['fields'] @@ -271,12 +271,12 @@ def execute(self, sql, params=None): # replace the fields with the corresponding 'id' self.fields = self.get_fields(fields) # Check if we need to update - if opts.has_key('update_fields'): + if 'update_fields' in opts: rows = self.fetchmany() self.update(rows, opts['update_fields']) def fetchone(self): - return self.fetchmany () + return self.fetchmany() def fetchmany(self, size=None): max_results = self.opts['max_results'] @@ -312,8 +312,8 @@ def get_raw_fields(self, fields=None): if fields: for f in fields: for c in content: - if c.has_key('schema'): - if c.has_key('clauseNames') and c['clauseNames'] and c['clauseNames'][0] == f: + if 'schema' in c: + if 'clauseNames' in c and c['clauseNames'] and c['clauseNames'][0] == f: ret.append(c) else: ret = content @@ -331,7 +331,7 @@ def get_fields(self, fields=None): if not self.raw_fields: self.raw_fields = self.get_raw_fields(fields) for f in self.raw_fields: - if f.has_key('schema') and f.has_key('clauseNames') and f['clauseNames']: + if 'schema' in f and 'clauseNames' in f and f['clauseNames']: schema = f['schema']['type'] array_type = None if schema == 'any' and f['schema']['custom']: @@ -346,12 +346,6 @@ def get_fields(self, fields=None): ret.append(FieldInfo(f['clauseNames'][0], schema, None, None, None, None, True, None, self.__normalize_field_name(f['name']), f['id'], array_type, editable, choices)) return ret - def escape_type(self, value): - if type(value) == unicode or type(value) == str: - return '"%s"' % value - else: - return value - def get_sql_qs(self, sql): raise NotImplementedError('missing get_sql_qs implementation') diff --git a/django_atlassian/backends/confluence/base.py b/django_atlassian/backends/confluence/base.py index 254ea24..e76a5bc 100644 --- a/django_atlassian/backends/confluence/base.py +++ b/django_atlassian/backends/confluence/base.py @@ -118,13 +118,13 @@ def create_row(self, row): class DatabaseConvertion(AtlassianDatabaseConvertion): def extract(self, data, field, raw_field): - if raw_field.has_key('returnName'): + if 'returnName' in raw_field: return_name = raw_field['returnName'] value = row for rn in return_name.split('.'): value = value[rn] return value - elif row.has_key(field[9]): + elif field[9] in row: return row[field[9]] else: return None diff --git a/django_atlassian/backends/jira/base.py b/django_atlassian/backends/jira/base.py index 0235ae2..3fb1c21 100644 --- a/django_atlassian/backends/jira/base.py +++ b/django_atlassian/backends/jira/base.py @@ -22,6 +22,7 @@ class DatabaseIntrospection(BaseDatabaseIntrospection): 'user': 'TextField', 'project': 'TextField', 'resolution': 'TextField', + 'status': 'TextField', 'com.pyxis.greenhopper.jira:gh-epic-status': 'TextField', } @@ -99,7 +100,7 @@ def get_relations(self, cursor, table_name): relations = {} content = cursor.get_raw_fields() for f in content: - if f.has_key('schema'): + if 'schema' in f: if f['schema']['type'] == 'any' and \ f['schema']['custom'] in self.data_type_relations: relations[f['clauseNames'][0]] = ('issues', 'issue') @@ -120,12 +121,12 @@ def get_constraints(self, cursor, table_name): class DatabaseCursor(AtlassianDatabaseCursor): arraysize = 100 - uri_search_pattern = '/rest/api/3/search?%(get_opts)s&startAt=%(start_at)s&maxResults=%(max_results)s' - uri_edit_pattern = '/rest/api/3/issue/%(issue_id)s' - uri_field = '/rest/api/3/field' + uri_search_pattern = '/rest/api/latest/search?%(get_opts)s&startAt=%(start_at)s&maxResults=%(max_results)s' + uri_edit_pattern = '/rest/api/latest/issue/%(issue_id)s' + uri_field = '/rest/api/latest/field' def _get_resolutions(self): - uri_resolution = '/rest/api/3/resolution' + uri_resolution = '/rest/api/latest/resolution' # Get the resolution types response = self.connection.get_request(uri_resolution) if response.status_code != requests.codes.ok: @@ -268,9 +269,9 @@ def create_row(self, row): class DatabaseConvertion(AtlassianDatabaseConvertion): def extract(self, data, field, raw_field): - if data.has_key(field[9]): + if field[9] in data: return data[field[9]] - elif data['fields'].has_key(field[9]): + elif field[9] in data['fields']: return data['fields'][field[9]] else: logger.error("Field with id %s and name %s not found", field[8], field[9]) @@ -293,18 +294,22 @@ def from_native(self, data, field): return None # Handle the parent link elif field[1] == 'com.atlassian.jpo:jpo-custom-field-parent': - if data.has_key('data'): + if 'data' in data: return data['data']['key'] else: return None # Handle the resolution elif field[1] == 'resolution' and data is not None: return data['name'] + elif field[1] == 'status' and data is not None: + return data['name'] elif field[1] == 'array' and data is not None: if field[10] == 'component': return [item['name'] for item in data] elif field[10] == 'string': return data + elif field[10] == 'version': + return [item['name'] for item in data] elif field[10] == 'issuelinks': issuelinks = [] try: diff --git a/django_atlassian/models/base.py b/django_atlassian/models/base.py new file mode 100644 index 0000000..bcfb5d0 --- /dev/null +++ b/django_atlassian/models/base.py @@ -0,0 +1,9 @@ +# base models for Atlassian services + + +class JiraModel: + pass + + +class ConfluanceModel: + pass diff --git a/django_atlassian/models/confluence.py b/django_atlassian/models/confluence.py index d96e0e0..d1918cc 100644 --- a/django_atlassian/models/confluence.py +++ b/django_atlassian/models/confluence.py @@ -4,7 +4,10 @@ from django.db import models -class Content(models.base.Model): +from .base import ConfluanceModel + + +class Content(models.base.Model, ConfluanceModel): """ Base class for all Confluence content models. """ diff --git a/django_atlassian/models/connect.py b/django_atlassian/models/connect.py index f228ba4..4ced5b5 100644 --- a/django_atlassian/models/connect.py +++ b/django_atlassian/models/connect.py @@ -1,17 +1,11 @@ # -*- coding: utf-8 -*- from __future__ import unicode_literals -import jwt -import time -import json -import urlparse -import urllib -import hashlib -import base64 import atlassian_jwt from django.db import models + class SecurityContext(models.base.Model): """ Stores the security context shared on the installation @@ -23,11 +17,9 @@ class SecurityContext(models.base.Model): client_key = models.CharField(max_length=512, null=False, blank=False) host = models.CharField(max_length=512, null=False, blank=False) - def create_token(self, method, uri): token = atlassian_jwt.encode_token(method, uri, self.key, self.shared_secret) return token - def __unicode__(self): return "%s: %s" % (self.key, self.host) diff --git a/django_atlassian/models/djira.py b/django_atlassian/models/djira.py index 1ee9719..ba9563a 100644 --- a/django_atlassian/models/djira.py +++ b/django_atlassian/models/djira.py @@ -25,11 +25,14 @@ from jira.resources import Issue as JiraIssue from jira.resilientsession import ResilientSession +from .base import JiraModel + logger = logging.getLogger('django_atlassian') + class IssueManager(Manager): - def create_from_json(self, json): + def create_from_json(self, json_data): """ Some modules notify with an Issue. In order to manage it in a django way we need to convert the json received into a real Issue instance object @@ -39,7 +42,7 @@ def create_from_json(self, json): args = {} for x in self.model.AtlassianMeta.description: field = self.model._meta.get_field(x[8]) - value = convert.extract(json, x, None) + value = convert.extract(json_data, x, None) if value: value = convert.from_native(value, x) if type(field) == models.ForeignKey and value: @@ -51,10 +54,12 @@ def create_from_json(self, json): class JiraManagerMixin(JIRA): def __init__(self, *args, **kwargs): + if not self.model: + return try: db_alias = router.db_for_read(self.model) db_settings = connections.databases[db_alias] - if db_settings['USER'] and db_settings['PASSWORD']: + if db_settings.get('USER') and db_settings.get('PASSWORD'): super(JiraManagerMixin, self).__init__( server=db_settings['NAME'], basic_auth=( @@ -62,7 +67,7 @@ def __init__(self, *args, **kwargs): db_settings['PASSWORD'] ) ) - elif db_settings['SECURITY']: + elif db_settings.get('SECURITY'): jwt = { 'secret': db_settings['SECURITY'].shared_secret, 'payload': {'iss': db_settings['SECURITY'].key}, @@ -76,6 +81,7 @@ def __init__(self, *args, **kwargs): class JiraManager(EmptyManager, JiraManagerMixin): + model = None def __init__(self, *args, **kwargs): super(JiraManager, self).__init__(None) @@ -91,9 +97,9 @@ class IssueLinkList(collections.MutableSequence): """ IssueLink abstraction """ - uri_get = '/rest/api/3/issue/%(key)s?fields=issuelinks' - uri_create = '/rest/api/3/issueLink' - uri_delete = '/rest/api/3/issueLink/%(link_id)s' + uri_get = '/rest/api/latest/issue/%(key)s?fields=issuelinks' + uri_create = '/rest/api/latest/issueLink' + uri_delete = '/rest/api/latest/issueLink/%(link_id)s' def __init__(self, model, db, link_type, inward): self.model = model @@ -111,9 +117,9 @@ def __len__(self): count = 0 for x in content['fields']['issuelinks']: if x['type']['id'] == self.link_type['id']: - if self.inward and x.has_key('inwardIssue'): + if self.inward and 'inwardIssue' in x: count = count + 1 - elif not self.inward and x.has_key('outwardIssue'): + elif not self.inward and 'outwardIssue' in x: count = count + 1 return count @@ -165,13 +171,13 @@ def __getlink__(self, index): link_id = 0 for x in content['fields']['issuelinks']: if x['type']['id'] == self.link_type['id']: - if self.inward and x.has_key('inwardIssue'): + if self.inward and 'inwardIssue' in x: if count == index: link = x['inwardIssue'] link_id = x['id'] break count = count + 1 - elif not self.inward and x.has_key('outwardIssue'): + elif not self.inward and 'outwardIssue' in x: if count == index: link = x['outwardIssue'] link_id = x['id'] @@ -189,8 +195,8 @@ class IssueLinks(object): db = None key = None - uri_get_all = '/rest/api/3/issueLinkType' - uri_get = '/rest/api/3/issue/%(key)s?fields=issuelinks' + uri_get_all = '/rest/api/latest/issueLinkType' + uri_get = '/rest/api/latest/issue/%(key)s?fields=issuelinks' def __init__(self, model): db_alias = router.db_for_read(model._meta.model) @@ -247,7 +253,7 @@ def __getattr__(self, attr): return "'Attachment' attribute {} not defined".format(attr) def delete(self): - uri = '/rest/api/3/attachment/{}'.format(self.id) + uri = '/rest/api/latest/attachment/{}'.format(self.id) try: response = self.db.connection.delete_request(uri) return response.status_code @@ -264,10 +270,11 @@ def __init__(self, *args, **kwargs): db = connections.databases[self.AtlassianMeta.db] options['server'] = db['NAME'] sc = db['SECURITY'] - jwt = { - 'secret': sc.shared_secret, - 'payload': {'iss': sc.key}, - } + if sc: + jwt = { + 'secret': sc.shared_secret, + 'payload': {'iss': sc.key}, + } else: # static models db = self.get_db().connection @@ -281,8 +288,23 @@ def __init__(self, *args, **kwargs): if jwt: self._session = self.__class__.jira._session +class AtlassianMeta: + """ + Base class for all JIRA related Meta. + """ -class Issue(models.base.Model, JiraIssueModel): + def __init__(self): + # The database name this model refers to. Even if this breaks django + # purpose of resuable models being independent of the database backend, + # for JIRA there's 1:1 relation between the database (connection) and the model + self.db = None + # The set of FieldInfo as returned by the introspection + # get_table_description(). Given that several REST API methods return a + # full issue in JSON, we can parse it directly without the need of another + # round trip to the server + self.description = [] + +class Issue(models.Model, JiraIssueModel, JiraModel): """ Base class for all JIRA Issue models. """ @@ -296,7 +318,7 @@ class Issue(models.base.Model, JiraIssueModel): def __init__(self, *args, **kwargs): super(Issue, self).__init__(*args, **kwargs) - self.find(self.jira_key) + #self.jira.find(self.jira_key) def __getattr__(self, name): if name == 'links': @@ -313,12 +335,12 @@ def get_db(self): def get_property(self, prop_name): # Create a connection - # Call curl -X GET https://jira-instance1.net/rest/api/3/issue/ENPR-4/properties/{propertyKey} + # Call curl -X GET https://jira-instance1.net/rest/api/latest/issue/ENPR-4/properties/{propertyKey} pass def set_property(self, prop_name, value): # Create a connection - # Call curl -X PUT -H "Content-type: application/json" https://jira-instance1.net/rest/api/3/issue/`ENPR-4`/properties/{propertyKey} -d '{"content":"Test if works on Jira Cloud", "completed" : 1}' + # Call curl -X PUT -H "Content-type: application/json" https://jira-instance1.net/rest/api/latest/issue/`ENPR-4`/properties/{propertyKey} -d '{"content":"Test if works on Jira Cloud", "completed" : 1}' pass def is_parent(self): @@ -371,22 +393,23 @@ def get_changelog(self): """ Get the Issue's changelog """ - uri = "/rest/api/3/issue/%(issue)s/changelog" % { 'issue': self.key } + uri = "/rest/api/latest/issue/%(issue)s/changelog" % { 'issue': self.key } response = self.get_db().connection.get_request(uri) response.raise_for_status() content = json.loads(response.content) + return content def get_statuses(self): """ Get the available statuses on the system """ - uri = "/rest/api/3/status" + uri = "/rest/api/latest/status" response = self.get_db().connection.get_request(uri) response.raise_for_status() content = json.loads(response.content) return content - + def get_attachment(self): """ Get an url list of attached files @@ -401,9 +424,9 @@ def add_attachment(self, attachment, filename=None): :param attachment: a file like object :param filename: a file name """ - uri = "/rest/api/3/issue/{}/attachments".format(self.key) + uri = "/rest/api/latest/issue/{}/attachments".format(self.key) if isinstance(attachment, str): - attachment = open(attachment, "rb") + attachment = open(attachment, "rb") if not filename: filename = os.path.basename(attachment.name) @@ -420,13 +443,13 @@ def delete_attachment(self, attachment): Delete attachment file :param attachment: it is an Attachment object """ - uri = '/rest/api/3/attachment/{}'.format(attachment.id) + uri = '/rest/api/latest/attachment/{}'.format(attachment.id) try: response = self.get_db().connection.delete_request(uri) return response.status_code except Exception as e: - raise e + raise e def __unicode__(self): return str(self.key) @@ -436,23 +459,6 @@ class Meta: managed = False -class AtlassianMeta: - """ - Base class for all JIRA related Meta. - """ - - def __init__(self): - # The database name this model refers to. Even if this breaks django - # purpose of resuable models being independent of the database backend, - # for JIRA there's 1:1 relation between the database (connection) and the model - self.db = None - # The set of FieldInfo as returned by the introspection - # get_table_description(). Given that several REST API methods return a - # full issue in JSON, we can parse it directly without the need of another - # round trip to the server - self.description = [] - - def create_model(name): # Create the module dynamically class Meta: @@ -479,6 +485,7 @@ def populate_model(db, model): am = getattr(model, 'AtlassianMeta', None) if not am: am = AtlassianMeta() + am.db = db.alias setattr(model, 'AtlassianMeta', am) # Check if the description is already populated if am.description: @@ -508,6 +515,18 @@ def populate_model(db, model): choices = row[12] is_relation = column_name in relations + rows_with_same_fields = [f for f in table_description if f[8] == field_name] + if len(rows_with_same_fields) > 1: + # In some not properly JIRA configurations we can meet with fields with same name and + # same db_column but with diffrent JIRA field id. + if (f for f in rows_with_same_fields if f.column == column_name and f.name == field_name): + logger.warning('{} field with {} db_column already presented. Skip.' + .format(field_name, column_name)) + continue + # In some not properly JIRA configurations we can meet with fields with same names and + # different JIRA field id. Let's add JIRA field id as suffix. + field_name = field_name.rstrip('_') + "_{}".format(row[9]) + field_name = field_name.rstrip('_') # Use the correct column name extra_params['db_column'] = column_name # Skip the primary key, we already have one @@ -550,7 +569,11 @@ def populate_model(db, model): continue if field_type == 'ForeignKey' and rel_to: - field = field_cls(rel_to, related_name='%sed' % field_name, **extra_params) + + field = field_cls(rel_to, + on_delete=models.CASCADE, + related_name='%sed' % field_name, + **extra_params) else: field = field_cls(**extra_params) diff --git a/django_atlassian/router.py b/django_atlassian/router.py index f08909e..47560a1 100644 --- a/django_atlassian/router.py +++ b/django_atlassian/router.py @@ -2,10 +2,10 @@ from django.conf import settings from django.db import connections -from django_atlassian.models.djira import Issue -from django_atlassian.models.confluence import Content +from django_atlassian.models.base import JiraModel, ConfluanceModel -class Router(object): + +class Router: def __init__(self): self.db_jira_alias = None @@ -21,10 +21,9 @@ def allow_migrate(self, db, app_label, model_name=None, **hints): return False if self.db_jira_alias == db: return False - return None def db_for_read(self, model, **hints): - if issubclass(model, Issue): + if issubclass(model, JiraModel): # Check if the model has the AtlassianMeta class atlassian_meta = getattr(model, 'AtlassianMeta', False) if not atlassian_meta: @@ -33,17 +32,18 @@ def db_for_read(self, model, **hints): if not atlassian_db: return self.db_jira_alias for alias, settings_dict in connections.databases.items(): - if settings_dict['ENGINE'] == 'django_atlassian.backends.jira' and \ - alias == atlassian_db: + if (settings_dict['ENGINE'] == 'django_atlassian.backends.jira' and + alias == atlassian_db): return alias return None - if issubclass(model, Content): + if issubclass(model, ConfluanceModel): return self.db_confluence_alias - return None def db_for_write(self, model, **hints): - if issubclass(model, Issue): + if not model: + return + if issubclass(model, JiraModel): # Check if the model has the AtlassianMeta class atlassian_meta = getattr(model, 'AtlassianMeta', False) if not atlassian_meta: @@ -52,11 +52,10 @@ def db_for_write(self, model, **hints): if not atlassian_db: return self.db_jira_alias for alias, settings_dict in connections.databases.items(): - if settings_dict['ENGINE'] == 'django_atlassian.backends.jira' and \ - alias == atlassian_db: + if (settings_dict['ENGINE'] == 'django_atlassian.backends.jira' and + alias == atlassian_db): return alias - if issubclass(model, Content): + if issubclass(model, ConfluanceModel): return self.db_confluence_alias - return None