Projet

Général

Profil

Paste
Télécharger (15,5 ko) Statistiques
| Branche: | Révision:

root / scripts_divers / migrer_taches_vers_redmine / goto_redmine.py @ e9b44dc1

1
#!/usr/bin/env python3
2

    
3
"""This script can migrate issues from drupal to redmine.
4

5
It is design be launched with the -i option: most variables you may need for
6
debugging are easily accessible from interpreter (like HTTP status codes).
7
We make some assertion based on HTTP status code. Here are the codet you may
8
wish to know:
9
- 200: OK
10
- 201: Created
11
- 404: Not found
12
- 403: Forbidden
13
- 500: Internal error
14
Redmine gives you more intel in its response. Read them!
15
Pandoc is required to convert html syntax to textile syntax
16
"""
17

    
18
import requests
19
import json
20
import sys
21
import datetime
22
import os #we need system() to call pandoc
23
import re
24

    
25
import constantes as cst
26

    
27
######## Global variables
28
REGEXP_FIND_IMG = re.compile('!/.*!')
29
REGEXP_NAME_IMG = re.compile('!.*/(.*)!')
30

    
31
######## Common functions
32

    
33
def handle_image(txt):
34
    "Images are not posted automatically. There are only few of them.\
35
    We just format the text with the correct textile syntax and when we post them,\
36
    we will add comment and node id into a file. They should be attached to the\
37
    correct issue afterwards"
38
    has_image = False
39
    # In textile, images are between !
40
    images = REGEXP_FIND_IMG.findall(txt)
41
    print(images)
42
    if images:
43
        has_image = True
44
        for image in images:
45
            img_name = REGEXP_NAME_IMG.sub(r'!\1!', image)
46
            txt.replace(image, img_name)
47
    return txt, has_image
48

    
49
def html2textile(txt):
50
    "Convert a txt from html to textile using pandoc"
51
    # We remove line breaks and tabs, otherwise the conversion doesn't work properly
52
    txt.replace('\n', '')
53
    txt.replace('\t', '')
54
    # pandoc can only manipulates files
55
    with open('tmp.html', 'w') as f:
56
        f.write(txt)
57
    os.system('pandoc -f html tmp.html -t textile -o tmp.textile')
58
    with open('tmp.textile', 'r') as f:
59
        txt = f.read()
60
    # Cleaning temporary files
61
    os.remove('tmp.html')
62
    os.remove('tmp.textile')
63
    return handle_image(txt)
64

    
65
def egalise(string, length):
66
    "Make the length of string equals to length if shorter"
67
    if len(string) < length:
68
        return ' '*(length - len(string)) + string
69
    else:
70
        return string
71

    
72
def percentage(integer):
73
    "Converts integer into a string used to indicate the percentage of completion\
74
    of a command"
75
    string = str(integer)
76
    string = egalise(string, 3)
77
    string = 'Completion: ' + string + '%'
78
    return string + '\b'*len(string)
79

    
80
def print_progress(str):
81
    sys.stdout.write(str)
82
    sys.stdout.flush()
83

    
84
def format_date(timestamp):
85
    str_timestamp = float(timestamp)
86
    date = datetime.datetime.fromtimestamp(str_timestamp)
87
    return date.strftime('%Y-%m-%d %H:%M:%S')
88

    
89

    
90

    
91
######## Definition of classes
92

    
93

    
94
class Comment:
95
    """Represents a drupal comment
96
    """
97

    
98
    def __init__(self, cid, author, post_date, content, has_img):
99
        self._cid = cid # comment id in drupal
100
        self._author = author
101
        self._post_date = post_date
102
        self._content = content
103
        # json representation, to be posted in redmine
104
        self._update = {'issue': {'notes': self._content }}
105
        self._update_json = json.dumps(self._update)
106
        self._resp = None #will be used to store the put response
107
        self._has_img = has_img
108

    
109
    def post(self, url, headers, iid, post_nb):
110
        "Post the comment to url with headers (required for authentication)"
111
        self._resp = requests.put(url, headers=headers, data=self._update_json)
112
        assert self._resp.status_code == 200
113

    
114
        # We write iid,author_id,created_on in comments.csv
115
        with open('comments.csv', 'a', encoding='utf8') as comments_csv:
116
            comments_csv.write('{},{},{}\n'.\
117
                        format(iid, cst.USER_ID[self._author], self._post_date))
118
        with open('fix_url_comments.csv', 'a', encoding='utf8') as fix_url_csv:
119
            fix_url_csv.write('{},{},{}\n'.format(self._cid, iid, post_nb))
120

    
121
    @property
122
    def post_date(self):
123
        return self._post_date
124

    
125
    @property
126
    def resp(self):
127
        return self._resp
128

    
129
    @property
130
    def cid(self):
131
        return self._cid
132

    
133
    @property
134
    def has_img(self):
135
        return self._has_img
136

    
137

    
138

    
139
class Updates:
140
    """Represents all the comments of a task
141
    """
142

    
143
    def __init__(self, comments):
144
        self._comments = comments
145

    
146
    def sort(self):
147
        "Sort all the updates by date of creation"
148
        sorted_date = False
149
        while not sorted_date:
150
            sorted_date = True
151
            i = 0
152
            while i < len(self._comments) - 1:
153
                if self._comments[i].post_date > self._comments[i + 1].post_date:
154
                    self._comments[i], self._comments[i + 1] = self._comments[i + 1],\
155
                                                               self._comments[i]
156
                    sorted_date = False
157
                i += 1
158

    
159
    def __getitem__(self, index):
160
        return self._comments[index]
161

    
162
    def __len__(self):
163
        return len(self._comments)
164

    
165
    def __iter__(self):
166
        self.__i = -1
167
        return self
168

    
169
    def __next__(self):
170
        self.__i += 1
171
        if self.__i >= len(self._comments) or len(self._comments) == 0:
172
            raise StopIteration
173
        return self._comments[self.__i]
174

    
175

    
176

    
177
class Issue:
178
    """Represents a drupal issue
179
    """
180

    
181
    def __init__(self, nid, comments):
182
        self._nid = nid #node id
183
        self._iid = None #issue id, unknown until creation
184
        self._resp = None #will be used to store the response of requests.post
185
        self._comments = Updates(comments)
186
        self._comments.sort()
187
        self._issue = self.give_redmine_issue(nid) #the actual content, it's a dict
188

    
189
    def give_redmine_status_id(self, node):
190
        "Translate the drupal status field to an integer representing the\
191
        redmine status id"
192
        drupal_status = ''
193
        for elt in node['field_avancement']:
194
            if "Terminée" in elt:
195
                drupal_status = 'Fermée'
196
                break
197
            elif "Fermée" in elt:
198
                drupal_status = 'Rejetée'
199
                break
200
            elif "pause" in elt:
201
                drupal_status = 'En pause'
202
                del elt
203
                break
204
        if not drupal_status:
205
            drupal_status = 'En cours'
206
        return cst.STATUS[drupal_status]
207

    
208
    def give_redmine_issue(self, nid):
209
        "Uses the nid to find the node and converts its content to something\
210
        redmine can understand. Read examples for more intels"
211
        node_json = requests.get(cst.BASE_URL + '/node/{}.json'.format(nid)).text
212
        node = json.loads(node_json)
213
        issue = dict()
214
        issue['project_id'] = cst.PROJECT_ID
215
        issue['tracker_id'] = cst.TRACKER_ID
216
        issue['subject'] = node['title']
217
        issue['description'], self._has_img = html2textile(node['body']['value'])
218
        # We get the name of the node
219
        self._name = re.findall(cst.REGEXP_NAME, node['url'])[0]
220
        # field_prioritaecute can be empty. We then assume it is normal
221
        if node['field_prioritaecute']:
222
            issue['priority_id'] = cst.PRIORITY[node['field_prioritaecute']]
223
        else:
224
            issue['priority_id'] = cst.PRIORITY['3 - Moyenne']
225
        # field_avancement can be empty. We then assume it is to be started
226
        if node['field_avancement']:
227
            issue['done_ratio'] = cst.DONE_RATIO[node['field_avancement'][0]]
228
        else:
229
            issue['done_ratio'] = cst.DONE_RATIO['À commencer']
230
        # Status id = open, fix, closed…
231
        issue['status_id'] = self.give_redmine_status_id(node)
232
        issue['fixed_version_id'] = cst.DRUPAL_VERSION[node['taxonomy_vocabulary_8']['id']]
233
        issue['created'] = format_date(node['created'])
234
        issue['author_id'] = node['author']['id']
235
        # Do we have attached files?
236
        if node['field_fichier']:
237
            self._has_files = True
238
        else:
239
            self._has_files = False
240
        return issue
241

    
242
    def post(self, url, headers):
243
        "Post the comment to url with headers (required for authentication)"
244
        issue = {'issue': self._issue}
245
        data = json.dumps(issue)
246

    
247
        self._resp = requests.post(url, headers=headers, data=data)
248

    
249
        assert self._resp.status_code == 201
250

    
251
        resp_json = json.loads(self._resp.text)
252
        self._iid = resp_json['issue']['id']
253

    
254
        # We write iid,author_id,created_on in issues.csv
255
        with open('issues.csv', 'a', encoding='utf8') as issues_csv:
256
            author_id = self._issue['author_id']
257
            redmine_author_id = cst.USER_ID[author_id]
258
            created_on = self._issue['created']
259
            issues_csv.write('{},{},{}\n'.\
260
                             format(self._iid, redmine_author_id, created_on))
261
        with open('fix_url_issues.csv', 'a') as fix_url:
262
            fix_url.write('{},{},{}\n'.format(self._nid, self._name, self._iid))
263

    
264
        # We post comments
265
        nb_comments = len(self._comments)
266
        i = 0
267
        for comment in self._comments:
268
            i += 1
269
            put_url =  cst.URL_ISSUES + '/{}.json'.format(self._iid)
270
            comment.post(put_url, headers, self._iid, i)
271
            print_progress(percentage(i//nb_comments*100))
272

    
273
        # We take care of images and files
274
        self.handle_image()
275
        self.handle_files()
276

    
277
    def handle_files(self):
278
        if self._has_files:
279
            with open('has_files.txt', 'a', encoding='utf8') as has_files_file:
280
                has_files_file.write('{}\n'.format(self._nid))
281

    
282
    def handle_image(self):
283
        for comment in self._comments:
284
            self._has_img = comment.has_img or self._has_img
285
        if self._has_img:
286
            with open('has_img.txt', 'a', encoding='utf8') as has_img_file:
287
                has_img_file.write('{}\n'.format(self._nid))
288

    
289
    @property
290
    def resp(self):
291
        return self._resp
292

    
293
    @property
294
    def comments_resp(self):
295
        resps = dict()
296
        for comment in self._comments:
297
            resps[comment.cid] = comment.resp
298
        return resps
299

    
300

    
301

    
302
class Laundry:
303
    """Contains all issues and has methods to perform the migration
304

305
    Iteration of this object traverses all issues.
306
    You can access any issue with the container notation
307
    """
308

    
309
    def __init__(self, url, test=True):
310
        self._issues = self.give_issues(url, test)
311

    
312
    def give_issues(self, url, test):
313
        "Returns a list of all issues"
314
        issues = []
315
        r = requests.get(url)
316

    
317
        assert r.status_code == 200
318

    
319
        # 1st element is 'Nid', and last is ''
320
        nids = r.text.split('\r\n')[1:-1]
321
        # for test, we only use 3 issues (faster)
322
        if test:
323
            nids = nids[:3]
324
        self.nb_task = len(nids)
325
        i = 0
326
        for nid in nids:
327
            i += 1
328
            comments = self.give_comments(nid)
329
            print('Fetching issue no {} on {}'.format(i, self.nb_task))
330
            issues.append(Issue(nid, comments))
331
        return issues
332

    
333
    def give_comments(self, nid):
334
        "Returns the list of all comments of a node"
335
        cids, comments_json = self.give_comments_json(nid)
336
        comments = []
337
        i = 0
338
        nb_comments = len(comments_json)
339
        for comment_json in comments_json:
340
            author = comment_json['name']
341
            post_date = format_date(comment_json['created'])
342
            content, has_img = html2textile(comment_json['comment_body']['value'])
343
            comments.append(Comment(cids[comments_json.index(comment_json)], author, post_date, content, has_img))
344
            i += 1
345
            print_progress(percentage(i//nb_comments*100))
346
        return comments
347

    
348
    def give_comments_json(self, nid):
349
        "Get the raw json version of the drupal comment"
350
        cids = self.give_comments_ids(nid)
351
        comments = list()
352
        for cid in cids:
353
            comment = requests.get(cst.BASE_URL + '/comment/' + cid + '.json')
354
            comments.append(json.loads(comment.text))
355
        return cids, comments
356

    
357
    def give_comments_ids(self, nid):
358
        "Get the cid (comnments id) for a node"
359
        headers = cst.Headers_GET
360
        headers['X-Redmine-API-Key'] = cst.SUBMITERS[cst.MANAGER]
361
        r = requests.get(cst.BASE_URL + '/entity_json/node/{}'.format(nid), headers=headers)
362
        page_json = json.loads(r.text)
363
        comments_json = page_json['comments']
364
        #If the issue has no comment, comments_json is a list, not a dict
365
        if comments_json:
366
            comments = list(comments_json.keys())
367
            return comments
368
        else:
369
            return list()
370

    
371
    def __iter__(self):
372
        self.__i = -1
373
        return self
374

    
375
    def __next__(self):
376
        self.__i += 1
377
        if self.__i >= len(self._issues):
378
            raise StopIteration
379
        return self._issues[self.__i]
380

    
381
    def __getitem__(self, index):
382
        return self._issues[index]
383

    
384
    def __len__(self):
385
        return len(self._issues)
386

    
387

    
388

    
389
class Redmine:
390
    """Main class.
391

392
    Allows the interaction with the program
393
    You can access any issue with the container notation.
394
    """
395
    def __init__(self, test=True):
396
        self.reset()
397
        self._test = test
398

    
399
    def reset(self):
400
        "Go to initial stage. All attributes are set to None"
401
        self._laundry = None
402
        self._headers = None
403
        self._headers_get = None
404

    
405
    def init(self, issues_file='issues.csv', x_redmine_api_key=cst.SUBMITERS[cst.MANAGER]):
406
        "Initialize the attribute for post uses"
407
        self._headers = cst.Headers
408
        self._headers['X-Redmine-API-Key'] = x_redmine_api_key
409
        self._laundry = Laundry(cst.LIST_TODO_CSV, self._test)
410

    
411
    def post(self, post_url=cst.URL_ISSUES_JSON):
412
        "Post all issues"
413
        nb_issues = len(self._laundry)
414
        i = 0
415
        for issue in self._laundry:
416
            i += 1
417
            print('Posting issues {} on {}'.format(i, nb_issues))
418
            issue.post(post_url, self._headers)
419

    
420
    def sweep(self):
421
        "Clean the redmine project of all issues."
422
        print('You are about to delete all issues on your redmine project.')
423
        ok = input('Do you wish to continue? (yes/no): ')
424

    
425
        if ok == 'yes':
426
            # Get the right headers
427
            self._headers_get = cst.Headers_GET
428
            self._headers_get['X-Redmine-API-Key'] = cst.SUBMITERS[cst.MANAGER]
429
            # Redmine give at maximum 100 issues. We may need to do it many times
430
            pass_number = 1
431
            while True:
432
                r = requests.get(cst.URL_ISSUES_JSON + '?status_id=*&limit=100',\
433
                                 headers=cst.Headers_GET)
434

    
435
                assert r.status_code == 200
436

    
437
                if not json.loads(r.text)['issues']: # There are no more issues to sweep
438
                    break
439
                print('Pass {}'.format(pass_number))
440
                taches_json = json.loads(r.text)['issues']
441
                # Print a nice completion percentage
442
                sys.stdout.flush()
443
                compt = 0
444
                print_progress(percentage(compt//len(taches_json)*100))
445
                for tache in taches_json:
446
                    tid = tache['id']
447
                    r = requests.delete(cst.URL_REDMINE + '/issues/{}.json'.format(tid),\
448
                                        headers=cst.Headers_GET)
449
                    compt += 1
450
                    print_progress(percentage(int(compt/len(taches_json)*100)))
451
                sys.stdout.write("\n")
452
                pass_number += 1
453
        else:
454
            print('Wise decision')
455

    
456
    def __getitem__(self, index):
457
        if self._laundry:
458
            return self._laundry[index]
459
        else:
460
            raise IndexError('Index out of range')
461

    
462

    
463

    
464

    
465
######## Main program
466
if __name__ == "__main__":
467
    redmine = Redmine(test=False)
468
    redmine.init()
469
    redmine.post()