Projet

Général

Profil

Révision e9b44dc1

Ajouté par Julien Enselme il y a plus de 10 ans

Version 2 de goto_redmine.py et constantes.py

Réécriture du script avec des classes afin de permettre une résolution
plus aisée des problèmes posés par l’ancien script :

  • Les captures d’écran ne sont pas présentes
  • Les liens vers les tâches sont cassés
  • L’ordre des commentaires n’est pas bon
  • Les informations sur les contributeurs et la date sont perdues Le script donne des informations sur la progression (ex post issue no 12 on 116)

goto_redmine.py et constantes.py ont été modifiés afin d’écrire
issues.csv et comments.csv qui permettent à fix-db de rétablir
directement dans la base de données les informations concernant les
contributeurs et la date de la contribution.

J'ai ajouté également des réponses de redmine lorsqu’on poste une tâche e$
commentaire pour information

Correction de constantes.py pour gianny

Voir les différences:

scripts_divers/migrer_taches_vers_redmine/goto_redmine.py
1
"""
2
Pandoc est requis pour convertir le html en textile !
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
3 16
"""
4 17

  
5
import url_parser  #permet de connaître les id des taches
6
import urllib.request #permet de récupérer une page web
7
import httplib2 #pour faire des requêtes http
18
import requests
8 19
import json
9
import re #pour les expressions régulières
10
import os #pour pouvoir faire appel à pandoc (commande system)
11

  
12
######## NB : cid : comment id, nid : node id, urls : urls des tâches
13

  
14
#Dictionnaire des gens dont on a la clé API
15
#id: clé
16
SUBMITERS = {'jenselme': '464c7c05b9bb53fb136092f1b9807ad91ec51321'}
17

  
18
#les entêtes des requêtes POST et PUT
19
Headers = {'content-type': 'application/json', 'X-Redmine-API-Key': ''}
20

  
21
#là où on poste les tâches
22
URL = 'https://forge.centrale-marseille.fr/issues'
23

  
24
#là où sont les tâches
25
LIST_TODO = 'http://localhost/portail/liste-tache'
26

  
27
#url de base de l’emplacement du contenu
28
BASE_URL = 'http://localhost/portail'
29
PROJECT_ID = 30
30
TRACKER_ID = 2
31

  
32

  
33
########## dictionnaires de correspondance
34
DONE_RATIO = {'En pause': 50, 'À commencer': 0, 'Entamée': 20, 'Bien avancée': 80, 'Terminée (success)': 100, 'Fermée (won\'t fix)': 100}
35
PRIORITY = {'5 - Très basse': 3, '4 - Basse': 3, '3 - Moyenne': 4, '2 - Haute': 5, '1 - Très haute': 6,\
36
        'Très basse': 3, 'Basse': 3, 'Moyenne': 4, 'Haute': 5, 'Très haute':6,\
37
        '0': 3, '1': 3, '2': 4, '3': 5, '4': 6}
38
STATUS = {'En cours': 2, 'Fermée': 5, 'Rejetée': 6, 'En pause': 7}
39
#NB sur le portail, on a les équivalences suivantes
40
#pour le champ version de drupal : 17 : drupal6, 18 : drupal7
41
DRUPAL_VERSION = {'17': 2, '18': 1}
42

  
43
def give_api_key(submiter):
44
    "Donne la clé API de submiter ou celle de jenselme si c’est la seule"
45
    if submiter in SUBMITERS:
46
        return SUBMITERS[submiter]
47
    else:
48
        return  SUBMITERS['jenselme']
49

  
50

  
51
def give_comments_ids(nid):
52
    "permet de récupérer les id des commentaires de la tâche nid"
53
    page = urllib.request.urlopen(BASE_URL + '/entity_json/node/' + nid).read()
54
    page_json = json.loads(page.decode('utf-8'))
55
    comments_json = page_json['comments']
56
    #S’il n’y a pas de commentaire, comments_json est une liste vide et pas un dictionnaire
57
    if comments_json:
58
        comments = list(comments_json.keys())
59
        comments.sort() #ce sont les clés d’un dictionnaire. Pas d’ordre à priori
60
        return comments
61
    else:
62
        return list()
63

  
64

  
65
def give_comments(cids):
66
    "Donne la liste du texte des commentaires pour chaque cid in cids"
67
    comments = list()
68
    for cid in cids:
69
        comment = urllib.request.urlopen(BASE_URL + '/comment/' + cid + '.json').read()
70
        comments.append(json.loads(comment.decode('utf-8')))
71
    return comments
72

  
73

  
74
def format(txt):
75
    "prend le texte en html et le renvoie en textile"
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
76 52
    txt.replace('\n', '')
77 53
    txt.replace('\t', '')
54
    # pandoc can only manipulates files
78 55
    with open('tmp.html', 'w') as f:
79 56
        f.write(txt)
80 57
    os.system('pandoc -f html tmp.html -t textile -o tmp.textile')
81 58
    with open('tmp.textile', 'r') as f:
82 59
        txt = f.read()
83
    return txt
84

  
85

  
86
def give_redmine_status_id(tache):
87
    drupal_status = ''
88
    for elt in tache['field_avancement']:
89
        if "Terminée" in elt:
90
            drupal_status = 'Fermée'
91
            break
92
        elif "Fermée" in elt:
93
            drupal_status = 'Rejetée'
94
            break
95
        elif "pause" in elt:
96
            drupal_status = 'En pause'
97
            del elt
98
            break
99
    if not drupal_status:
100
        drupal_status = 'En cours'
101
    return STATUS[drupal_status]
102

  
103

  
104
def give_redmine_issue(tache):
105
    issue = dict()
106
    issue['project_id'] = PROJECT_ID
107
    issue['tracker_id'] = TRACKER_ID
108
    issue['subject'] = tache['title']
109
    issue['description'] = format(tache['body']['value'])
110
    #de temps en temps, le champ priorité est vide. On met 'Normale' dans ce cas
111
    if tache['field_prioritaecute']:
112
        issue['priority_id'] = PRIORITY[tache['field_prioritaecute']]
113
    else:
114
        issue['priority_id'] = PRIORITY['3 - Moyenne']
115
    if tache['field_avancement']:
116
        issue['done_ratio'] = DONE_RATIO[tache['field_avancement'][0]]
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
117 69
    else:
118
        issue['done_ratio'] = DONE_RATIO['À commencer']
119
    issue['status_id'] = give_redmine_status_id(tache)
120
    issue['fixed_version_id'] = DRUPAL_VERSION[tache['taxonomy_vocabulary_8']['id']]
121
    return issue
122

  
123

  
124
######### Main
125

  
126
nids, urls = url_parser.give_json_urls(LIST_TODO, BASE_URL)
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)
127 79

  
128
h = httplib2.Http()
80
def print_progress(str):
81
    sys.stdout.write(str)
82
    sys.stdout.flush()
129 83

  
130
for post_url in urls:
131
    nid = nids[urls.index(post_url)]
132
    print(nid)
133
    tache_json = urllib.request.urlopen(post_url)
134
    tache_drupal = json.loads(tache_json.read().decode('utf-8'))
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')
135 88

  
136
    cids = give_comments_ids(nid)
137
    comments_drupal = give_comments(cids)
138

  
139
    issue = {}
140
    issue['issue'] = give_redmine_issue(tache_drupal)
141
    data = json.dumps(issue)
142

  
143
    Headers['X-Redmine-API-Key'] = SUBMITERS['jenselme']
144

  
145
    resp, content = h.request(URL + '.json', 'POST', body=data, headers=Headers)
146

  
147
    #on récupère l’issue id pour savoir où poster les commentaires
148
    iid = re.findall(r',"id":([0-9]*),', content.decode('utf-8'))[0]
149

  
150
    #on a besoin de l’url à laquelle on met les commentaires, pour changer le status
151
    put_url = URL + '/' + iid + '.json'
152
    for index, comment in enumerate(comments_drupal):
153
        submiter = comment['name']  #le premier est celui qui a soumis le node
154
        Headers['X-Redmine-API-Key'] = give_api_key(submiter)
155
        #si la personne n’a pas sa clé, on modifie le commentaire
156
        comment_body = format(comment['comment_body']['value'])
157
        if not submiter in SUBMITERS:
158
            comment_body = "_{}_ a dit que :\n\n{}".format(submiter, comment_body)
159
        update = {}
160
        update['issue'] = {'notes': comment_body}
161
        data = json.dumps(update)
162
        h.request(put_url, 'PUT', body=data, headers=Headers)
163 89

  
164
    #Les taches sont crées avec le status nouveau peu importe ce qu’il y a dans le json
165
    #on modifie le status après coup
166
    update_status = {'issue': {'status_id': issue['issue']['status_id']}}
167
    data = json.dumps(update_status)
168
    h.request(put_url, 'PUT', body=data, headers=Headers)
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()

Formats disponibles : Unified diff