##// END OF EJS Templates
scripts: drop isort --wrap-length 160 - it is broken with py3 and not really necessary...
Mads Kiilerich -
r8157:5b1f4302 default
parent child Browse files
Show More
@@ -1,2423 +1,2423 b''
1 1 # -*- coding: utf-8 -*-
2 2 # This program is free software: you can redistribute it and/or modify
3 3 # it under the terms of the GNU General Public License as published by
4 4 # the Free Software Foundation, either version 3 of the License, or
5 5 # (at your option) any later version.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 """
15 15 kallithea.controllers.api.api
16 16 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
17 17
18 18 API controller for Kallithea
19 19
20 20 This file was forked by the Kallithea project in July 2014.
21 21 Original author and date, and relevant copyright and licensing information is below:
22 22 :created_on: Aug 20, 2011
23 23 :author: marcink
24 24 :copyright: (c) 2013 RhodeCode GmbH, and others.
25 25 :license: GPLv3, see LICENSE.md for more details.
26 26 """
27 27
28 28 import logging
29 29 import traceback
30 30 from datetime import datetime
31 31
32 32 from tg import request
33 33
34 34 from kallithea.controllers.api import JSONRPCController, JSONRPCError
35 from kallithea.lib.auth import (
36 AuthUser, HasPermissionAny, HasPermissionAnyDecorator, HasRepoGroupPermissionLevel, HasRepoPermissionLevel, HasUserGroupPermissionLevel)
35 from kallithea.lib.auth import (AuthUser, HasPermissionAny, HasPermissionAnyDecorator, HasRepoGroupPermissionLevel, HasRepoPermissionLevel,
36 HasUserGroupPermissionLevel)
37 37 from kallithea.lib.exceptions import DefaultUserException, UserGroupsAssignedException
38 38 from kallithea.lib.utils import action_logger, repo2db_mapper
39 39 from kallithea.lib.utils2 import OAttr, Optional
40 40 from kallithea.lib.vcs.backends.base import EmptyChangeset
41 41 from kallithea.lib.vcs.exceptions import EmptyRepositoryError
42 42 from kallithea.model.changeset_status import ChangesetStatusModel
43 43 from kallithea.model.comment import ChangesetCommentsModel
44 44 from kallithea.model.db import ChangesetStatus, Gist, Permission, PullRequest, RepoGroup, Repository, Setting, User, UserGroup, UserIpMap
45 45 from kallithea.model.gist import GistModel
46 46 from kallithea.model.meta import Session
47 47 from kallithea.model.pull_request import PullRequestModel
48 48 from kallithea.model.repo import RepoModel
49 49 from kallithea.model.repo_group import RepoGroupModel
50 50 from kallithea.model.scm import ScmModel, UserGroupList
51 51 from kallithea.model.user import UserModel
52 52 from kallithea.model.user_group import UserGroupModel
53 53
54 54
55 55 log = logging.getLogger(__name__)
56 56
57 57
58 58 def store_update(updates, attr, name):
59 59 """
60 60 Stores param in updates dict if it's not instance of Optional
61 61 allows easy updates of passed in params
62 62 """
63 63 if not isinstance(attr, Optional):
64 64 updates[name] = attr
65 65
66 66
67 67 def get_user_or_error(userid):
68 68 """
69 69 Get user by id or name or return JsonRPCError if not found
70 70
71 71 :param userid:
72 72 """
73 73 user = UserModel().get_user(userid)
74 74 if user is None:
75 75 raise JSONRPCError("user `%s` does not exist" % (userid,))
76 76 return user
77 77
78 78
79 79 def get_repo_or_error(repoid):
80 80 """
81 81 Get repo by id or name or return JsonRPCError if not found
82 82
83 83 :param repoid:
84 84 """
85 85 repo = RepoModel().get_repo(repoid)
86 86 if repo is None:
87 87 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
88 88 return repo
89 89
90 90
91 91 def get_repo_group_or_error(repogroupid):
92 92 """
93 93 Get repo group by id or name or return JsonRPCError if not found
94 94
95 95 :param repogroupid:
96 96 """
97 97 repo_group = RepoGroup.guess_instance(repogroupid)
98 98 if repo_group is None:
99 99 raise JSONRPCError(
100 100 'repository group `%s` does not exist' % (repogroupid,))
101 101 return repo_group
102 102
103 103
104 104 def get_user_group_or_error(usergroupid):
105 105 """
106 106 Get user group by id or name or return JsonRPCError if not found
107 107
108 108 :param usergroupid:
109 109 """
110 110 user_group = UserGroupModel().get_group(usergroupid)
111 111 if user_group is None:
112 112 raise JSONRPCError('user group `%s` does not exist' % (usergroupid,))
113 113 return user_group
114 114
115 115
116 116 def get_perm_or_error(permid, prefix=None):
117 117 """
118 118 Get permission by id or name or return JsonRPCError if not found
119 119
120 120 :param permid:
121 121 """
122 122 perm = Permission.get_by_key(permid)
123 123 if perm is None:
124 124 raise JSONRPCError('permission `%s` does not exist' % (permid,))
125 125 if prefix:
126 126 if not perm.permission_name.startswith(prefix):
127 127 raise JSONRPCError('permission `%s` is invalid, '
128 128 'should start with %s' % (permid, prefix))
129 129 return perm
130 130
131 131
132 132 def get_gist_or_error(gistid):
133 133 """
134 134 Get gist by id or gist_access_id or return JsonRPCError if not found
135 135
136 136 :param gistid:
137 137 """
138 138 gist = GistModel().get_gist(gistid)
139 139 if gist is None:
140 140 raise JSONRPCError('gist `%s` does not exist' % (gistid,))
141 141 return gist
142 142
143 143
144 144 class ApiController(JSONRPCController):
145 145 """
146 146 API Controller
147 147
148 148 The authenticated user can be found as request.authuser.
149 149
150 150 Example function::
151 151
152 152 def func(arg1, arg2,...):
153 153 pass
154 154
155 155 Each function should also **raise** JSONRPCError for any
156 156 errors that happens.
157 157 """
158 158
159 159 @HasPermissionAnyDecorator('hg.admin')
160 160 def test(self, args):
161 161 return args
162 162
163 163 @HasPermissionAnyDecorator('hg.admin')
164 164 def pull(self, repoid, clone_uri=Optional(None)):
165 165 """
166 166 Triggers a pull from remote location on given repo. Can be used to
167 167 automatically keep remote repos up to date. This command can be executed
168 168 only using api_key belonging to user with admin rights
169 169
170 170 :param repoid: repository name or repository id
171 171 :type repoid: str or int
172 172 :param clone_uri: repository URI to pull from (optional)
173 173 :type clone_uri: str
174 174
175 175 OUTPUT::
176 176
177 177 id : <id_given_in_input>
178 178 result : {
179 179 "msg": "Pulled from `<repository name>`"
180 180 "repository": "<repository name>"
181 181 }
182 182 error : null
183 183
184 184 ERROR OUTPUT::
185 185
186 186 id : <id_given_in_input>
187 187 result : null
188 188 error : {
189 189 "Unable to pull changes from `<reponame>`"
190 190 }
191 191
192 192 """
193 193
194 194 repo = get_repo_or_error(repoid)
195 195
196 196 try:
197 197 ScmModel().pull_changes(repo.repo_name,
198 198 request.authuser.username,
199 199 request.ip_addr,
200 200 clone_uri=Optional.extract(clone_uri))
201 201 return dict(
202 202 msg='Pulled from `%s`' % repo.repo_name,
203 203 repository=repo.repo_name
204 204 )
205 205 except Exception:
206 206 log.error(traceback.format_exc())
207 207 raise JSONRPCError(
208 208 'Unable to pull changes from `%s`' % repo.repo_name
209 209 )
210 210
211 211 @HasPermissionAnyDecorator('hg.admin')
212 212 def rescan_repos(self, remove_obsolete=Optional(False)):
213 213 """
214 214 Triggers rescan repositories action. If remove_obsolete is set
215 215 than also delete repos that are in database but not in the filesystem.
216 216 aka "clean zombies". This command can be executed only using api_key
217 217 belonging to user with admin rights.
218 218
219 219 :param remove_obsolete: deletes repositories from
220 220 database that are not found on the filesystem
221 221 :type remove_obsolete: Optional(bool)
222 222
223 223 OUTPUT::
224 224
225 225 id : <id_given_in_input>
226 226 result : {
227 227 'added': [<added repository name>,...]
228 228 'removed': [<removed repository name>,...]
229 229 }
230 230 error : null
231 231
232 232 ERROR OUTPUT::
233 233
234 234 id : <id_given_in_input>
235 235 result : null
236 236 error : {
237 237 'Error occurred during rescan repositories action'
238 238 }
239 239
240 240 """
241 241
242 242 try:
243 243 rm_obsolete = Optional.extract(remove_obsolete)
244 244 added, removed = repo2db_mapper(ScmModel().repo_scan(),
245 245 remove_obsolete=rm_obsolete)
246 246 return {'added': added, 'removed': removed}
247 247 except Exception:
248 248 log.error(traceback.format_exc())
249 249 raise JSONRPCError(
250 250 'Error occurred during rescan repositories action'
251 251 )
252 252
253 253 def invalidate_cache(self, repoid):
254 254 """
255 255 Invalidate cache for repository.
256 256 This command can be executed only using api_key belonging to user with admin
257 257 rights or regular user that have write or admin or write access to repository.
258 258
259 259 :param repoid: repository name or repository id
260 260 :type repoid: str or int
261 261
262 262 OUTPUT::
263 263
264 264 id : <id_given_in_input>
265 265 result : {
266 266 'msg': Cache for repository `<repository name>` was invalidated,
267 267 'repository': <repository name>
268 268 }
269 269 error : null
270 270
271 271 ERROR OUTPUT::
272 272
273 273 id : <id_given_in_input>
274 274 result : null
275 275 error : {
276 276 'Error occurred during cache invalidation action'
277 277 }
278 278
279 279 """
280 280 repo = get_repo_or_error(repoid)
281 281 if not HasPermissionAny('hg.admin')():
282 282 if not HasRepoPermissionLevel('write')(repo.repo_name):
283 283 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
284 284
285 285 try:
286 286 ScmModel().mark_for_invalidation(repo.repo_name)
287 287 return dict(
288 288 msg='Cache for repository `%s` was invalidated' % (repoid,),
289 289 repository=repo.repo_name
290 290 )
291 291 except Exception:
292 292 log.error(traceback.format_exc())
293 293 raise JSONRPCError(
294 294 'Error occurred during cache invalidation action'
295 295 )
296 296
297 297 @HasPermissionAnyDecorator('hg.admin')
298 298 def get_ip(self, userid=Optional(OAttr('apiuser'))):
299 299 """
300 300 Shows IP address as seen from Kallithea server, together with all
301 301 defined IP addresses for given user. If userid is not passed data is
302 302 returned for user who's calling this function.
303 303 This command can be executed only using api_key belonging to user with
304 304 admin rights.
305 305
306 306 :param userid: username to show ips for
307 307 :type userid: Optional(str or int)
308 308
309 309 OUTPUT::
310 310
311 311 id : <id_given_in_input>
312 312 result : {
313 313 "server_ip_addr": "<ip_from_clien>",
314 314 "user_ips": [
315 315 {
316 316 "ip_addr": "<ip_with_mask>",
317 317 "ip_range": ["<start_ip>", "<end_ip>"],
318 318 },
319 319 ...
320 320 ]
321 321 }
322 322
323 323 """
324 324 if isinstance(userid, Optional):
325 325 userid = request.authuser.user_id
326 326 user = get_user_or_error(userid)
327 327 ips = UserIpMap.query().filter(UserIpMap.user == user).all()
328 328 return dict(
329 329 server_ip_addr=request.ip_addr,
330 330 user_ips=ips
331 331 )
332 332
333 333 # alias for old
334 334 show_ip = get_ip
335 335
336 336 @HasPermissionAnyDecorator('hg.admin')
337 337 def get_server_info(self):
338 338 """
339 339 return server info, including Kallithea version and installed packages
340 340
341 341
342 342 OUTPUT::
343 343
344 344 id : <id_given_in_input>
345 345 result : {
346 346 'modules': [<module name>,...]
347 347 'py_version': <python version>,
348 348 'platform': <platform type>,
349 349 'kallithea_version': <kallithea version>
350 350 }
351 351 error : null
352 352 """
353 353 return Setting.get_server_info()
354 354
355 355 def get_user(self, userid=Optional(OAttr('apiuser'))):
356 356 """
357 357 Gets a user by username or user_id, Returns empty result if user is
358 358 not found. If userid param is skipped it is set to id of user who is
359 359 calling this method. This command can be executed only using api_key
360 360 belonging to user with admin rights, or regular users that cannot
361 361 specify different userid than theirs
362 362
363 363 :param userid: user to get data for
364 364 :type userid: Optional(str or int)
365 365
366 366 OUTPUT::
367 367
368 368 id : <id_given_in_input>
369 369 result: None if user does not exist or
370 370 {
371 371 "user_id" : "<user_id>",
372 372 "api_key" : "<api_key>",
373 373 "api_keys": "[<list of all API keys including additional ones>]"
374 374 "username" : "<username>",
375 375 "firstname": "<firstname>",
376 376 "lastname" : "<lastname>",
377 377 "email" : "<email>",
378 378 "emails": "[<list of all emails including additional ones>]",
379 379 "ip_addresses": "[<ip_address_for_user>,...]",
380 380 "active" : "<bool: user active>",
381 381 "admin" :  "<bool: user is admin>",
382 382 "extern_name" : "<extern_name>",
383 383 "extern_type" : "<extern type>
384 384 "last_login": "<last_login>",
385 385 "permissions": {
386 386 "global": ["hg.create.repository",
387 387 "repository.read",
388 388 "hg.register.manual_activate"],
389 389 "repositories": {"repo1": "repository.none"},
390 390 "repositories_groups": {"Group1": "group.read"}
391 391 },
392 392 }
393 393
394 394 error: null
395 395
396 396 """
397 397 if not HasPermissionAny('hg.admin')():
398 398 # make sure normal user does not pass someone else userid,
399 399 # he is not allowed to do that
400 400 if not isinstance(userid, Optional) and userid != request.authuser.user_id:
401 401 raise JSONRPCError(
402 402 'userid is not the same as your user'
403 403 )
404 404
405 405 if isinstance(userid, Optional):
406 406 userid = request.authuser.user_id
407 407
408 408 user = get_user_or_error(userid)
409 409 data = user.get_api_data()
410 410 data['permissions'] = AuthUser(user_id=user.user_id).permissions
411 411 return data
412 412
413 413 @HasPermissionAnyDecorator('hg.admin')
414 414 def get_users(self):
415 415 """
416 416 Lists all existing users. This command can be executed only using api_key
417 417 belonging to user with admin rights.
418 418
419 419
420 420 OUTPUT::
421 421
422 422 id : <id_given_in_input>
423 423 result: [<user_object>, ...]
424 424 error: null
425 425 """
426 426
427 427 return [
428 428 user.get_api_data()
429 429 for user in User.query()
430 430 .order_by(User.username)
431 431 .filter_by(is_default_user=False)
432 432 ]
433 433
434 434 @HasPermissionAnyDecorator('hg.admin')
435 435 def create_user(self, username, email, password=Optional(''),
436 436 firstname=Optional(''), lastname=Optional(''),
437 437 active=Optional(True), admin=Optional(False),
438 438 extern_type=Optional(User.DEFAULT_AUTH_TYPE),
439 439 extern_name=Optional('')):
440 440 """
441 441 Creates new user. Returns new user object. This command can
442 442 be executed only using api_key belonging to user with admin rights.
443 443
444 444 :param username: new username
445 445 :type username: str or int
446 446 :param email: email
447 447 :type email: str
448 448 :param password: password
449 449 :type password: Optional(str)
450 450 :param firstname: firstname
451 451 :type firstname: Optional(str)
452 452 :param lastname: lastname
453 453 :type lastname: Optional(str)
454 454 :param active: active
455 455 :type active: Optional(bool)
456 456 :param admin: admin
457 457 :type admin: Optional(bool)
458 458 :param extern_name: name of extern
459 459 :type extern_name: Optional(str)
460 460 :param extern_type: extern_type
461 461 :type extern_type: Optional(str)
462 462
463 463
464 464 OUTPUT::
465 465
466 466 id : <id_given_in_input>
467 467 result: {
468 468 "msg" : "created new user `<username>`",
469 469 "user": <user_obj>
470 470 }
471 471 error: null
472 472
473 473 ERROR OUTPUT::
474 474
475 475 id : <id_given_in_input>
476 476 result : null
477 477 error : {
478 478 "user `<username>` already exist"
479 479 or
480 480 "email `<email>` already exist"
481 481 or
482 482 "failed to create user `<username>`"
483 483 }
484 484
485 485 """
486 486
487 487 if User.get_by_username(username):
488 488 raise JSONRPCError("user `%s` already exist" % (username,))
489 489
490 490 if User.get_by_email(email):
491 491 raise JSONRPCError("email `%s` already exist" % (email,))
492 492
493 493 try:
494 494 user = UserModel().create_or_update(
495 495 username=Optional.extract(username),
496 496 password=Optional.extract(password),
497 497 email=Optional.extract(email),
498 498 firstname=Optional.extract(firstname),
499 499 lastname=Optional.extract(lastname),
500 500 active=Optional.extract(active),
501 501 admin=Optional.extract(admin),
502 502 extern_type=Optional.extract(extern_type),
503 503 extern_name=Optional.extract(extern_name)
504 504 )
505 505 Session().commit()
506 506 return dict(
507 507 msg='created new user `%s`' % username,
508 508 user=user.get_api_data()
509 509 )
510 510 except Exception:
511 511 log.error(traceback.format_exc())
512 512 raise JSONRPCError('failed to create user `%s`' % (username,))
513 513
514 514 @HasPermissionAnyDecorator('hg.admin')
515 515 def update_user(self, userid, username=Optional(None),
516 516 email=Optional(None), password=Optional(None),
517 517 firstname=Optional(None), lastname=Optional(None),
518 518 active=Optional(None), admin=Optional(None),
519 519 extern_type=Optional(None), extern_name=Optional(None)):
520 520 """
521 521 updates given user if such user exists. This command can
522 522 be executed only using api_key belonging to user with admin rights.
523 523
524 524 :param userid: userid to update
525 525 :type userid: str or int
526 526 :param username: new username
527 527 :type username: str or int
528 528 :param email: email
529 529 :type email: str
530 530 :param password: password
531 531 :type password: Optional(str)
532 532 :param firstname: firstname
533 533 :type firstname: Optional(str)
534 534 :param lastname: lastname
535 535 :type lastname: Optional(str)
536 536 :param active: active
537 537 :type active: Optional(bool)
538 538 :param admin: admin
539 539 :type admin: Optional(bool)
540 540 :param extern_name:
541 541 :type extern_name: Optional(str)
542 542 :param extern_type:
543 543 :type extern_type: Optional(str)
544 544
545 545
546 546 OUTPUT::
547 547
548 548 id : <id_given_in_input>
549 549 result: {
550 550 "msg" : "updated user ID:<userid> <username>",
551 551 "user": <user_object>,
552 552 }
553 553 error: null
554 554
555 555 ERROR OUTPUT::
556 556
557 557 id : <id_given_in_input>
558 558 result : null
559 559 error : {
560 560 "failed to update user `<username>`"
561 561 }
562 562
563 563 """
564 564
565 565 user = get_user_or_error(userid)
566 566
567 567 # only non optional arguments will be stored in updates
568 568 updates = {}
569 569
570 570 try:
571 571
572 572 store_update(updates, username, 'username')
573 573 store_update(updates, password, 'password')
574 574 store_update(updates, email, 'email')
575 575 store_update(updates, firstname, 'name')
576 576 store_update(updates, lastname, 'lastname')
577 577 store_update(updates, active, 'active')
578 578 store_update(updates, admin, 'admin')
579 579 store_update(updates, extern_name, 'extern_name')
580 580 store_update(updates, extern_type, 'extern_type')
581 581
582 582 user = UserModel().update_user(user, **updates)
583 583 Session().commit()
584 584 return dict(
585 585 msg='updated user ID:%s %s' % (user.user_id, user.username),
586 586 user=user.get_api_data()
587 587 )
588 588 except DefaultUserException:
589 589 log.error(traceback.format_exc())
590 590 raise JSONRPCError('editing default user is forbidden')
591 591 except Exception:
592 592 log.error(traceback.format_exc())
593 593 raise JSONRPCError('failed to update user `%s`' % (userid,))
594 594
595 595 @HasPermissionAnyDecorator('hg.admin')
596 596 def delete_user(self, userid):
597 597 """
598 598 deletes given user if such user exists. This command can
599 599 be executed only using api_key belonging to user with admin rights.
600 600
601 601 :param userid: user to delete
602 602 :type userid: str or int
603 603
604 604 OUTPUT::
605 605
606 606 id : <id_given_in_input>
607 607 result: {
608 608 "msg" : "deleted user ID:<userid> <username>",
609 609 "user": null
610 610 }
611 611 error: null
612 612
613 613 ERROR OUTPUT::
614 614
615 615 id : <id_given_in_input>
616 616 result : null
617 617 error : {
618 618 "failed to delete user ID:<userid> <username>"
619 619 }
620 620
621 621 """
622 622 user = get_user_or_error(userid)
623 623
624 624 try:
625 625 UserModel().delete(userid)
626 626 Session().commit()
627 627 return dict(
628 628 msg='deleted user ID:%s %s' % (user.user_id, user.username),
629 629 user=None
630 630 )
631 631 except Exception:
632 632
633 633 log.error(traceback.format_exc())
634 634 raise JSONRPCError('failed to delete user ID:%s %s'
635 635 % (user.user_id, user.username))
636 636
637 637 # permission check inside
638 638 def get_user_group(self, usergroupid):
639 639 """
640 640 Gets an existing user group. This command can be executed only using api_key
641 641 belonging to user with admin rights or user who has at least
642 642 read access to user group.
643 643
644 644 :param usergroupid: id of user_group to edit
645 645 :type usergroupid: str or int
646 646
647 647 OUTPUT::
648 648
649 649 id : <id_given_in_input>
650 650 result : None if group not exist
651 651 {
652 652 "users_group_id" : "<id>",
653 653 "group_name" : "<groupname>",
654 654 "active": "<bool>",
655 655 "members" : [<user_obj>,...]
656 656 }
657 657 error : null
658 658
659 659 """
660 660 user_group = get_user_group_or_error(usergroupid)
661 661 if not HasPermissionAny('hg.admin')():
662 662 if not HasUserGroupPermissionLevel('read')(user_group.users_group_name):
663 663 raise JSONRPCError('user group `%s` does not exist' % (usergroupid,))
664 664
665 665 data = user_group.get_api_data()
666 666 return data
667 667
668 668 # permission check inside
669 669 def get_user_groups(self):
670 670 """
671 671 Lists all existing user groups. This command can be executed only using
672 672 api_key belonging to user with admin rights or user who has at least
673 673 read access to user group.
674 674
675 675
676 676 OUTPUT::
677 677
678 678 id : <id_given_in_input>
679 679 result : [<user_group_obj>,...]
680 680 error : null
681 681 """
682 682
683 683 return [
684 684 user_group.get_api_data()
685 685 for user_group in UserGroupList(UserGroup.query().all(), perm_level='read')
686 686 ]
687 687
688 688 @HasPermissionAnyDecorator('hg.admin', 'hg.usergroup.create.true')
689 689 def create_user_group(self, group_name, description=Optional(''),
690 690 owner=Optional(OAttr('apiuser')), active=Optional(True)):
691 691 """
692 692 Creates new user group. This command can be executed only using api_key
693 693 belonging to user with admin rights or an user who has create user group
694 694 permission
695 695
696 696 :param group_name: name of new user group
697 697 :type group_name: str
698 698 :param description: group description
699 699 :type description: str
700 700 :param owner: owner of group. If not passed apiuser is the owner
701 701 :type owner: Optional(str or int)
702 702 :param active: group is active
703 703 :type active: Optional(bool)
704 704
705 705 OUTPUT::
706 706
707 707 id : <id_given_in_input>
708 708 result: {
709 709 "msg": "created new user group `<groupname>`",
710 710 "user_group": <user_group_object>
711 711 }
712 712 error: null
713 713
714 714 ERROR OUTPUT::
715 715
716 716 id : <id_given_in_input>
717 717 result : null
718 718 error : {
719 719 "user group `<group name>` already exist"
720 720 or
721 721 "failed to create group `<group name>`"
722 722 }
723 723
724 724 """
725 725
726 726 if UserGroupModel().get_by_name(group_name):
727 727 raise JSONRPCError("user group `%s` already exist" % (group_name,))
728 728
729 729 try:
730 730 if isinstance(owner, Optional):
731 731 owner = request.authuser.user_id
732 732
733 733 owner = get_user_or_error(owner)
734 734 active = Optional.extract(active)
735 735 description = Optional.extract(description)
736 736 ug = UserGroupModel().create(name=group_name, description=description,
737 737 owner=owner, active=active)
738 738 Session().commit()
739 739 return dict(
740 740 msg='created new user group `%s`' % group_name,
741 741 user_group=ug.get_api_data()
742 742 )
743 743 except Exception:
744 744 log.error(traceback.format_exc())
745 745 raise JSONRPCError('failed to create group `%s`' % (group_name,))
746 746
747 747 # permission check inside
748 748 def update_user_group(self, usergroupid, group_name=Optional(''),
749 749 description=Optional(''), owner=Optional(None),
750 750 active=Optional(True)):
751 751 """
752 752 Updates given usergroup. This command can be executed only using api_key
753 753 belonging to user with admin rights or an admin of given user group
754 754
755 755 :param usergroupid: id of user group to update
756 756 :type usergroupid: str or int
757 757 :param group_name: name of new user group
758 758 :type group_name: str
759 759 :param description: group description
760 760 :type description: str
761 761 :param owner: owner of group.
762 762 :type owner: Optional(str or int)
763 763 :param active: group is active
764 764 :type active: Optional(bool)
765 765
766 766 OUTPUT::
767 767
768 768 id : <id_given_in_input>
769 769 result : {
770 770 "msg": 'updated user group ID:<user group id> <user group name>',
771 771 "user_group": <user_group_object>
772 772 }
773 773 error : null
774 774
775 775 ERROR OUTPUT::
776 776
777 777 id : <id_given_in_input>
778 778 result : null
779 779 error : {
780 780 "failed to update user group `<user group name>`"
781 781 }
782 782
783 783 """
784 784 user_group = get_user_group_or_error(usergroupid)
785 785 if not HasPermissionAny('hg.admin')():
786 786 if not HasUserGroupPermissionLevel('admin')(user_group.users_group_name):
787 787 raise JSONRPCError('user group `%s` does not exist' % (usergroupid,))
788 788
789 789 if not isinstance(owner, Optional):
790 790 owner = get_user_or_error(owner)
791 791
792 792 updates = {}
793 793 store_update(updates, group_name, 'users_group_name')
794 794 store_update(updates, description, 'user_group_description')
795 795 store_update(updates, owner, 'owner')
796 796 store_update(updates, active, 'users_group_active')
797 797 try:
798 798 UserGroupModel().update(user_group, updates)
799 799 Session().commit()
800 800 return dict(
801 801 msg='updated user group ID:%s %s' % (user_group.users_group_id,
802 802 user_group.users_group_name),
803 803 user_group=user_group.get_api_data()
804 804 )
805 805 except Exception:
806 806 log.error(traceback.format_exc())
807 807 raise JSONRPCError('failed to update user group `%s`' % (usergroupid,))
808 808
809 809 # permission check inside
810 810 def delete_user_group(self, usergroupid):
811 811 """
812 812 Delete given user group by user group id or name.
813 813 This command can be executed only using api_key
814 814 belonging to user with admin rights or an admin of given user group
815 815
816 816 :param usergroupid:
817 817 :type usergroupid: int
818 818
819 819 OUTPUT::
820 820
821 821 id : <id_given_in_input>
822 822 result : {
823 823 "msg": "deleted user group ID:<user_group_id> <user_group_name>"
824 824 }
825 825 error : null
826 826
827 827 ERROR OUTPUT::
828 828
829 829 id : <id_given_in_input>
830 830 result : null
831 831 error : {
832 832 "failed to delete user group ID:<user_group_id> <user_group_name>"
833 833 or
834 834 "RepoGroup assigned to <repo_groups_list>"
835 835 }
836 836
837 837 """
838 838 user_group = get_user_group_or_error(usergroupid)
839 839 if not HasPermissionAny('hg.admin')():
840 840 if not HasUserGroupPermissionLevel('admin')(user_group.users_group_name):
841 841 raise JSONRPCError('user group `%s` does not exist' % (usergroupid,))
842 842
843 843 try:
844 844 UserGroupModel().delete(user_group)
845 845 Session().commit()
846 846 return dict(
847 847 msg='deleted user group ID:%s %s' %
848 848 (user_group.users_group_id, user_group.users_group_name),
849 849 user_group=None
850 850 )
851 851 except UserGroupsAssignedException as e:
852 852 log.error(traceback.format_exc())
853 853 raise JSONRPCError(str(e))
854 854 except Exception:
855 855 log.error(traceback.format_exc())
856 856 raise JSONRPCError('failed to delete user group ID:%s %s' %
857 857 (user_group.users_group_id,
858 858 user_group.users_group_name)
859 859 )
860 860
861 861 # permission check inside
862 862 def add_user_to_user_group(self, usergroupid, userid):
863 863 """
864 864 Adds a user to a user group. If user exists in that group success will be
865 865 `false`. This command can be executed only using api_key
866 866 belonging to user with admin rights or an admin of given user group
867 867
868 868 :param usergroupid:
869 869 :type usergroupid: int
870 870 :param userid:
871 871 :type userid: int
872 872
873 873 OUTPUT::
874 874
875 875 id : <id_given_in_input>
876 876 result : {
877 877 "success": True|False # depends on if member is in group
878 878 "msg": "added member `<username>` to user group `<groupname>` |
879 879 User is already in that group"
880 880
881 881 }
882 882 error : null
883 883
884 884 ERROR OUTPUT::
885 885
886 886 id : <id_given_in_input>
887 887 result : null
888 888 error : {
889 889 "failed to add member to user group `<user_group_name>`"
890 890 }
891 891
892 892 """
893 893 user = get_user_or_error(userid)
894 894 user_group = get_user_group_or_error(usergroupid)
895 895 if not HasPermissionAny('hg.admin')():
896 896 if not HasUserGroupPermissionLevel('admin')(user_group.users_group_name):
897 897 raise JSONRPCError('user group `%s` does not exist' % (usergroupid,))
898 898
899 899 try:
900 900 ugm = UserGroupModel().add_user_to_group(user_group, user)
901 901 success = True if ugm is not True else False
902 902 msg = 'added member `%s` to user group `%s`' % (
903 903 user.username, user_group.users_group_name
904 904 )
905 905 msg = msg if success else 'User is already in that group'
906 906 Session().commit()
907 907
908 908 return dict(
909 909 success=success,
910 910 msg=msg
911 911 )
912 912 except Exception:
913 913 log.error(traceback.format_exc())
914 914 raise JSONRPCError(
915 915 'failed to add member to user group `%s`' % (
916 916 user_group.users_group_name,
917 917 )
918 918 )
919 919
920 920 # permission check inside
921 921 def remove_user_from_user_group(self, usergroupid, userid):
922 922 """
923 923 Removes a user from a user group. If user is not in given group success will
924 924 be `false`. This command can be executed only
925 925 using api_key belonging to user with admin rights or an admin of given user group
926 926
927 927 :param usergroupid:
928 928 :param userid:
929 929
930 930
931 931 OUTPUT::
932 932
933 933 id : <id_given_in_input>
934 934 result: {
935 935 "success": True|False, # depends on if member is in group
936 936 "msg": "removed member <username> from user group <groupname> |
937 937 User wasn't in group"
938 938 }
939 939 error: null
940 940
941 941 """
942 942 user = get_user_or_error(userid)
943 943 user_group = get_user_group_or_error(usergroupid)
944 944 if not HasPermissionAny('hg.admin')():
945 945 if not HasUserGroupPermissionLevel('admin')(user_group.users_group_name):
946 946 raise JSONRPCError('user group `%s` does not exist' % (usergroupid,))
947 947
948 948 try:
949 949 success = UserGroupModel().remove_user_from_group(user_group, user)
950 950 msg = 'removed member `%s` from user group `%s`' % (
951 951 user.username, user_group.users_group_name
952 952 )
953 953 msg = msg if success else "User wasn't in group"
954 954 Session().commit()
955 955 return dict(success=success, msg=msg)
956 956 except Exception:
957 957 log.error(traceback.format_exc())
958 958 raise JSONRPCError(
959 959 'failed to remove member from user group `%s`' % (
960 960 user_group.users_group_name,
961 961 )
962 962 )
963 963
964 964 # permission check inside
965 965 def get_repo(self, repoid,
966 966 with_revision_names=Optional(False),
967 967 with_pullrequests=Optional(False)):
968 968 """
969 969 Gets an existing repository by it's name or repository_id. Members will return
970 970 either users_group or user associated to that repository. This command can be
971 971 executed only using api_key belonging to user with admin
972 972 rights or regular user that have at least read access to repository.
973 973
974 974 :param repoid: repository name or repository id
975 975 :type repoid: str or int
976 976
977 977 OUTPUT::
978 978
979 979 id : <id_given_in_input>
980 980 result : {
981 981 {
982 982 "repo_id" : "<repo_id>",
983 983 "repo_name" : "<reponame>"
984 984 "repo_type" : "<repo_type>",
985 985 "clone_uri" : "<clone_uri>",
986 986 "enable_downloads": "<bool>",
987 987 "enable_statistics": "<bool>",
988 988 "private": "<bool>",
989 989 "created_on" : "<date_time_created>",
990 990 "description" : "<description>",
991 991 "landing_rev": "<landing_rev>",
992 992 "last_changeset": {
993 993 "author": "<full_author>",
994 994 "date": "<date_time_of_commit>",
995 995 "message": "<commit_message>",
996 996 "raw_id": "<raw_id>",
997 997 "revision": "<numeric_revision>",
998 998 "short_id": "<short_id>"
999 999 }
1000 1000 "owner": "<repo_owner>",
1001 1001 "fork_of": "<name_of_fork_parent>",
1002 1002 "members" : [
1003 1003 {
1004 1004 "name": "<username>",
1005 1005 "type" : "user",
1006 1006 "permission" : "repository.(read|write|admin)"
1007 1007 },
1008 1008
1009 1009 {
1010 1010 "name": "<usergroup name>",
1011 1011 "type" : "user_group",
1012 1012 "permission" : "usergroup.(read|write|admin)"
1013 1013 },
1014 1014
1015 1015 ]
1016 1016 "followers": [<user_obj>, ...],
1017 1017 <if with_revision_names == True>
1018 1018 "tags": {
1019 1019 "<tagname>": "<raw_id>",
1020 1020 ...
1021 1021 },
1022 1022 "branches": {
1023 1023 "<branchname>": "<raw_id>",
1024 1024 ...
1025 1025 },
1026 1026 "bookmarks": {
1027 1027 "<bookmarkname>": "<raw_id>",
1028 1028 ...
1029 1029 },
1030 1030 }
1031 1031 }
1032 1032 error : null
1033 1033
1034 1034 """
1035 1035 repo = get_repo_or_error(repoid)
1036 1036
1037 1037 if not HasPermissionAny('hg.admin')():
1038 1038 if not HasRepoPermissionLevel('read')(repo.repo_name):
1039 1039 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
1040 1040
1041 1041 members = []
1042 1042 for user in repo.repo_to_perm:
1043 1043 perm = user.permission.permission_name
1044 1044 user = user.user
1045 1045 user_data = {
1046 1046 'name': user.username,
1047 1047 'type': "user",
1048 1048 'permission': perm
1049 1049 }
1050 1050 members.append(user_data)
1051 1051
1052 1052 for user_group in repo.users_group_to_perm:
1053 1053 perm = user_group.permission.permission_name
1054 1054 user_group = user_group.users_group
1055 1055 user_group_data = {
1056 1056 'name': user_group.users_group_name,
1057 1057 'type': "user_group",
1058 1058 'permission': perm
1059 1059 }
1060 1060 members.append(user_group_data)
1061 1061
1062 1062 followers = [
1063 1063 uf.user.get_api_data()
1064 1064 for uf in repo.followers
1065 1065 ]
1066 1066
1067 1067 data = repo.get_api_data(with_revision_names=Optional.extract(with_revision_names),
1068 1068 with_pullrequests=Optional.extract(with_pullrequests))
1069 1069 data['members'] = members
1070 1070 data['followers'] = followers
1071 1071 return data
1072 1072
1073 1073 # permission check inside
1074 1074 def get_repos(self):
1075 1075 """
1076 1076 Lists all existing repositories. This command can be executed only using
1077 1077 api_key belonging to user with admin rights or regular user that have
1078 1078 admin, write or read access to repository.
1079 1079
1080 1080
1081 1081 OUTPUT::
1082 1082
1083 1083 id : <id_given_in_input>
1084 1084 result: [
1085 1085 {
1086 1086 "repo_id" : "<repo_id>",
1087 1087 "repo_name" : "<reponame>"
1088 1088 "repo_type" : "<repo_type>",
1089 1089 "clone_uri" : "<clone_uri>",
1090 1090 "private": : "<bool>",
1091 1091 "created_on" : "<datetimecreated>",
1092 1092 "description" : "<description>",
1093 1093 "landing_rev": "<landing_rev>",
1094 1094 "owner": "<repo_owner>",
1095 1095 "fork_of": "<name_of_fork_parent>",
1096 1096 "enable_downloads": "<bool>",
1097 1097 "enable_statistics": "<bool>",
1098 1098 },
1099 1099
1100 1100 ]
1101 1101 error: null
1102 1102 """
1103 1103 if not HasPermissionAny('hg.admin')():
1104 1104 repos = RepoModel().get_all_user_repos(user=request.authuser.user_id)
1105 1105 else:
1106 1106 repos = Repository.query()
1107 1107
1108 1108 return [
1109 1109 repo.get_api_data()
1110 1110 for repo in repos
1111 1111 ]
1112 1112
1113 1113 # permission check inside
1114 1114 def get_repo_nodes(self, repoid, revision, root_path,
1115 1115 ret_type=Optional('all')):
1116 1116 """
1117 1117 returns a list of nodes and it's children in a flat list for a given path
1118 1118 at given revision. It's possible to specify ret_type to show only `files` or
1119 1119 `dirs`. This command can be executed only using api_key belonging to
1120 1120 user with admin rights or regular user that have at least read access to repository.
1121 1121
1122 1122 :param repoid: repository name or repository id
1123 1123 :type repoid: str or int
1124 1124 :param revision: revision for which listing should be done
1125 1125 :type revision: str
1126 1126 :param root_path: path from which start displaying
1127 1127 :type root_path: str
1128 1128 :param ret_type: return type 'all|files|dirs' nodes
1129 1129 :type ret_type: Optional(str)
1130 1130
1131 1131
1132 1132 OUTPUT::
1133 1133
1134 1134 id : <id_given_in_input>
1135 1135 result: [
1136 1136 {
1137 1137 "name" : "<name>"
1138 1138 "type" : "<type>",
1139 1139 },
1140 1140
1141 1141 ]
1142 1142 error: null
1143 1143 """
1144 1144 repo = get_repo_or_error(repoid)
1145 1145
1146 1146 if not HasPermissionAny('hg.admin')():
1147 1147 if not HasRepoPermissionLevel('read')(repo.repo_name):
1148 1148 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
1149 1149
1150 1150 ret_type = Optional.extract(ret_type)
1151 1151 _map = {}
1152 1152 try:
1153 1153 _d, _f = ScmModel().get_nodes(repo, revision, root_path,
1154 1154 flat=False)
1155 1155 _map = {
1156 1156 'all': _d + _f,
1157 1157 'files': _f,
1158 1158 'dirs': _d,
1159 1159 }
1160 1160 return _map[ret_type]
1161 1161 except KeyError:
1162 1162 raise JSONRPCError('ret_type must be one of %s'
1163 1163 % (','.join(sorted(_map))))
1164 1164 except Exception:
1165 1165 log.error(traceback.format_exc())
1166 1166 raise JSONRPCError(
1167 1167 'failed to get repo: `%s` nodes' % repo.repo_name
1168 1168 )
1169 1169
1170 1170 @HasPermissionAnyDecorator('hg.admin', 'hg.create.repository')
1171 1171 def create_repo(self, repo_name, owner=Optional(OAttr('apiuser')),
1172 1172 repo_type=Optional('hg'), description=Optional(''),
1173 1173 private=Optional(False), clone_uri=Optional(None),
1174 1174 landing_rev=Optional('rev:tip'),
1175 1175 enable_statistics=Optional(False),
1176 1176 enable_downloads=Optional(False),
1177 1177 copy_permissions=Optional(False)):
1178 1178 """
1179 1179 Creates a repository. The repository name contains the full path, but the
1180 1180 parent repository group must exist. For example "foo/bar/baz" require the groups
1181 1181 "foo" and "bar" (with "foo" as parent), and create "baz" repository with
1182 1182 "bar" as group. This command can be executed only using api_key
1183 1183 belonging to user with admin rights or regular user that have create
1184 1184 repository permission. Regular users cannot specify owner parameter
1185 1185
1186 1186 :param repo_name: repository name
1187 1187 :type repo_name: str
1188 1188 :param owner: user_id or username
1189 1189 :type owner: Optional(str)
1190 1190 :param repo_type: 'hg' or 'git'
1191 1191 :type repo_type: Optional(str)
1192 1192 :param description: repository description
1193 1193 :type description: Optional(str)
1194 1194 :param private:
1195 1195 :type private: bool
1196 1196 :param clone_uri:
1197 1197 :type clone_uri: str
1198 1198 :param landing_rev: <rev_type>:<rev>
1199 1199 :type landing_rev: str
1200 1200 :param enable_downloads:
1201 1201 :type enable_downloads: bool
1202 1202 :param enable_statistics:
1203 1203 :type enable_statistics: bool
1204 1204 :param copy_permissions: Copy permission from group that repository is
1205 1205 being created.
1206 1206 :type copy_permissions: bool
1207 1207
1208 1208 OUTPUT::
1209 1209
1210 1210 id : <id_given_in_input>
1211 1211 result: {
1212 1212 "msg": "Created new repository `<reponame>`",
1213 1213 "success": true,
1214 1214 "task": "<celery task id or None if done sync>"
1215 1215 }
1216 1216 error: null
1217 1217
1218 1218 ERROR OUTPUT::
1219 1219
1220 1220 id : <id_given_in_input>
1221 1221 result : null
1222 1222 error : {
1223 1223 'failed to create repository `<repo_name>`
1224 1224 }
1225 1225
1226 1226 """
1227 1227 if not HasPermissionAny('hg.admin')():
1228 1228 if not isinstance(owner, Optional):
1229 1229 # forbid setting owner for non-admins
1230 1230 raise JSONRPCError(
1231 1231 'Only Kallithea admin can specify `owner` param'
1232 1232 )
1233 1233 if isinstance(owner, Optional):
1234 1234 owner = request.authuser.user_id
1235 1235
1236 1236 owner = get_user_or_error(owner)
1237 1237
1238 1238 if RepoModel().get_by_repo_name(repo_name):
1239 1239 raise JSONRPCError("repo `%s` already exist" % repo_name)
1240 1240
1241 1241 defs = Setting.get_default_repo_settings(strip_prefix=True)
1242 1242 if isinstance(private, Optional):
1243 1243 private = defs.get('repo_private') or Optional.extract(private)
1244 1244 if isinstance(repo_type, Optional):
1245 1245 repo_type = defs.get('repo_type')
1246 1246 if isinstance(enable_statistics, Optional):
1247 1247 enable_statistics = defs.get('repo_enable_statistics')
1248 1248 if isinstance(enable_downloads, Optional):
1249 1249 enable_downloads = defs.get('repo_enable_downloads')
1250 1250
1251 1251 clone_uri = Optional.extract(clone_uri)
1252 1252 description = Optional.extract(description)
1253 1253 landing_rev = Optional.extract(landing_rev)
1254 1254 copy_permissions = Optional.extract(copy_permissions)
1255 1255
1256 1256 try:
1257 1257 repo_name_parts = repo_name.split('/')
1258 1258 repo_group = None
1259 1259 if len(repo_name_parts) > 1:
1260 1260 group_name = '/'.join(repo_name_parts[:-1])
1261 1261 repo_group = RepoGroup.get_by_group_name(group_name)
1262 1262 if repo_group is None:
1263 1263 raise JSONRPCError("repo group `%s` not found" % group_name)
1264 1264 data = dict(
1265 1265 repo_name=repo_name_parts[-1],
1266 1266 repo_name_full=repo_name,
1267 1267 repo_type=repo_type,
1268 1268 repo_description=description,
1269 1269 owner=owner,
1270 1270 repo_private=private,
1271 1271 clone_uri=clone_uri,
1272 1272 repo_group=repo_group,
1273 1273 repo_landing_rev=landing_rev,
1274 1274 enable_statistics=enable_statistics,
1275 1275 enable_downloads=enable_downloads,
1276 1276 repo_copy_permissions=copy_permissions,
1277 1277 )
1278 1278
1279 1279 task = RepoModel().create(form_data=data, cur_user=owner)
1280 1280 task_id = task.task_id
1281 1281 # no commit, it's done in RepoModel, or async via celery
1282 1282 return dict(
1283 1283 msg="Created new repository `%s`" % (repo_name,),
1284 1284 success=True, # cannot return the repo data here since fork
1285 1285 # can be done async
1286 1286 task=task_id
1287 1287 )
1288 1288 except Exception:
1289 1289 log.error(traceback.format_exc())
1290 1290 raise JSONRPCError(
1291 1291 'failed to create repository `%s`' % (repo_name,))
1292 1292
1293 1293 # permission check inside
1294 1294 def update_repo(self, repoid, name=Optional(None),
1295 1295 owner=Optional(OAttr('apiuser')),
1296 1296 group=Optional(None),
1297 1297 description=Optional(''), private=Optional(False),
1298 1298 clone_uri=Optional(None), landing_rev=Optional('rev:tip'),
1299 1299 enable_statistics=Optional(False),
1300 1300 enable_downloads=Optional(False)):
1301 1301
1302 1302 """
1303 1303 Updates repo
1304 1304
1305 1305 :param repoid: repository name or repository id
1306 1306 :type repoid: str or int
1307 1307 :param name:
1308 1308 :param owner:
1309 1309 :param group:
1310 1310 :param description:
1311 1311 :param private:
1312 1312 :param clone_uri:
1313 1313 :param landing_rev:
1314 1314 :param enable_statistics:
1315 1315 :param enable_downloads:
1316 1316 """
1317 1317 repo = get_repo_or_error(repoid)
1318 1318 if not HasPermissionAny('hg.admin')():
1319 1319 if not HasRepoPermissionLevel('admin')(repo.repo_name):
1320 1320 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
1321 1321
1322 1322 if (name != repo.repo_name and
1323 1323 not HasPermissionAny('hg.create.repository')()
1324 1324 ):
1325 1325 raise JSONRPCError('no permission to create (or move) repositories')
1326 1326
1327 1327 if not isinstance(owner, Optional):
1328 1328 # forbid setting owner for non-admins
1329 1329 raise JSONRPCError(
1330 1330 'Only Kallithea admin can specify `owner` param'
1331 1331 )
1332 1332
1333 1333 updates = {}
1334 1334 repo_group = group
1335 1335 if not isinstance(repo_group, Optional):
1336 1336 repo_group = get_repo_group_or_error(repo_group)
1337 1337 repo_group = repo_group.group_id
1338 1338 try:
1339 1339 store_update(updates, name, 'repo_name')
1340 1340 store_update(updates, repo_group, 'repo_group')
1341 1341 store_update(updates, owner, 'owner')
1342 1342 store_update(updates, description, 'repo_description')
1343 1343 store_update(updates, private, 'repo_private')
1344 1344 store_update(updates, clone_uri, 'clone_uri')
1345 1345 store_update(updates, landing_rev, 'repo_landing_rev')
1346 1346 store_update(updates, enable_statistics, 'repo_enable_statistics')
1347 1347 store_update(updates, enable_downloads, 'repo_enable_downloads')
1348 1348
1349 1349 RepoModel().update(repo, **updates)
1350 1350 Session().commit()
1351 1351 return dict(
1352 1352 msg='updated repo ID:%s %s' % (repo.repo_id, repo.repo_name),
1353 1353 repository=repo.get_api_data()
1354 1354 )
1355 1355 except Exception:
1356 1356 log.error(traceback.format_exc())
1357 1357 raise JSONRPCError('failed to update repo `%s`' % repoid)
1358 1358
1359 1359 @HasPermissionAnyDecorator('hg.admin', 'hg.fork.repository')
1360 1360 def fork_repo(self, repoid, fork_name,
1361 1361 owner=Optional(OAttr('apiuser')),
1362 1362 description=Optional(''), copy_permissions=Optional(False),
1363 1363 private=Optional(False), landing_rev=Optional('rev:tip')):
1364 1364 """
1365 1365 Creates a fork of given repo. In case of using celery this will
1366 1366 immediately return success message, while fork is going to be created
1367 1367 asynchronous. This command can be executed only using api_key belonging to
1368 1368 user with admin rights or regular user that have fork permission, and at least
1369 1369 read access to forking repository. Regular users cannot specify owner parameter.
1370 1370
1371 1371 :param repoid: repository name or repository id
1372 1372 :type repoid: str or int
1373 1373 :param fork_name:
1374 1374 :param owner:
1375 1375 :param description:
1376 1376 :param copy_permissions:
1377 1377 :param private:
1378 1378 :param landing_rev:
1379 1379
1380 1380 INPUT::
1381 1381
1382 1382 id : <id_for_response>
1383 1383 api_key : "<api_key>"
1384 1384 args: {
1385 1385 "repoid" : "<reponame or repo_id>",
1386 1386 "fork_name": "<forkname>",
1387 1387 "owner": "<username or user_id = Optional(=apiuser)>",
1388 1388 "description": "<description>",
1389 1389 "copy_permissions": "<bool>",
1390 1390 "private": "<bool>",
1391 1391 "landing_rev": "<landing_rev>"
1392 1392 }
1393 1393
1394 1394 OUTPUT::
1395 1395
1396 1396 id : <id_given_in_input>
1397 1397 result: {
1398 1398 "msg": "Created fork of `<reponame>` as `<forkname>`",
1399 1399 "success": true,
1400 1400 "task": "<celery task id or None if done sync>"
1401 1401 }
1402 1402 error: null
1403 1403
1404 1404 """
1405 1405 repo = get_repo_or_error(repoid)
1406 1406 repo_name = repo.repo_name
1407 1407
1408 1408 _repo = RepoModel().get_by_repo_name(fork_name)
1409 1409 if _repo:
1410 1410 type_ = 'fork' if _repo.fork else 'repo'
1411 1411 raise JSONRPCError("%s `%s` already exist" % (type_, fork_name))
1412 1412
1413 1413 if HasPermissionAny('hg.admin')():
1414 1414 pass
1415 1415 elif HasRepoPermissionLevel('read')(repo.repo_name):
1416 1416 if not isinstance(owner, Optional):
1417 1417 # forbid setting owner for non-admins
1418 1418 raise JSONRPCError(
1419 1419 'Only Kallithea admin can specify `owner` param'
1420 1420 )
1421 1421
1422 1422 if not HasPermissionAny('hg.create.repository')():
1423 1423 raise JSONRPCError('no permission to create repositories')
1424 1424 else:
1425 1425 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
1426 1426
1427 1427 if isinstance(owner, Optional):
1428 1428 owner = request.authuser.user_id
1429 1429
1430 1430 owner = get_user_or_error(owner)
1431 1431
1432 1432 try:
1433 1433 fork_name_parts = fork_name.split('/')
1434 1434 repo_group = None
1435 1435 if len(fork_name_parts) > 1:
1436 1436 group_name = '/'.join(fork_name_parts[:-1])
1437 1437 repo_group = RepoGroup.get_by_group_name(group_name)
1438 1438 if repo_group is None:
1439 1439 raise JSONRPCError("repo group `%s` not found" % group_name)
1440 1440
1441 1441 form_data = dict(
1442 1442 repo_name=fork_name_parts[-1],
1443 1443 repo_name_full=fork_name,
1444 1444 repo_group=repo_group,
1445 1445 repo_type=repo.repo_type,
1446 1446 description=Optional.extract(description),
1447 1447 private=Optional.extract(private),
1448 1448 copy_permissions=Optional.extract(copy_permissions),
1449 1449 landing_rev=Optional.extract(landing_rev),
1450 1450 update_after_clone=False,
1451 1451 fork_parent_id=repo.repo_id,
1452 1452 )
1453 1453 task = RepoModel().create_fork(form_data, cur_user=owner)
1454 1454 # no commit, it's done in RepoModel, or async via celery
1455 1455 task_id = task.task_id
1456 1456 return dict(
1457 1457 msg='Created fork of `%s` as `%s`' % (repo.repo_name,
1458 1458 fork_name),
1459 1459 success=True, # cannot return the repo data here since fork
1460 1460 # can be done async
1461 1461 task=task_id
1462 1462 )
1463 1463 except Exception:
1464 1464 log.error(traceback.format_exc())
1465 1465 raise JSONRPCError(
1466 1466 'failed to fork repository `%s` as `%s`' % (repo_name,
1467 1467 fork_name)
1468 1468 )
1469 1469
1470 1470 # permission check inside
1471 1471 def delete_repo(self, repoid, forks=Optional('')):
1472 1472 """
1473 1473 Deletes a repository. This command can be executed only using api_key belonging
1474 1474 to user with admin rights or regular user that have admin access to repository.
1475 1475 When `forks` param is set it's possible to detach or delete forks of deleting
1476 1476 repository
1477 1477
1478 1478 :param repoid: repository name or repository id
1479 1479 :type repoid: str or int
1480 1480 :param forks: `detach` or `delete`, what do do with attached forks for repo
1481 1481 :type forks: Optional(str)
1482 1482
1483 1483 OUTPUT::
1484 1484
1485 1485 id : <id_given_in_input>
1486 1486 result: {
1487 1487 "msg": "Deleted repository `<reponame>`",
1488 1488 "success": true
1489 1489 }
1490 1490 error: null
1491 1491
1492 1492 """
1493 1493 repo = get_repo_or_error(repoid)
1494 1494
1495 1495 if not HasPermissionAny('hg.admin')():
1496 1496 if not HasRepoPermissionLevel('admin')(repo.repo_name):
1497 1497 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
1498 1498
1499 1499 try:
1500 1500 handle_forks = Optional.extract(forks)
1501 1501 _forks_msg = ''
1502 1502 _forks = [f for f in repo.forks]
1503 1503 if handle_forks == 'detach':
1504 1504 _forks_msg = ' ' + 'Detached %s forks' % len(_forks)
1505 1505 elif handle_forks == 'delete':
1506 1506 _forks_msg = ' ' + 'Deleted %s forks' % len(_forks)
1507 1507 elif _forks:
1508 1508 raise JSONRPCError(
1509 1509 'Cannot delete `%s` it still contains attached forks' %
1510 1510 (repo.repo_name,)
1511 1511 )
1512 1512
1513 1513 RepoModel().delete(repo, forks=forks)
1514 1514 Session().commit()
1515 1515 return dict(
1516 1516 msg='Deleted repository `%s`%s' % (repo.repo_name, _forks_msg),
1517 1517 success=True
1518 1518 )
1519 1519 except Exception:
1520 1520 log.error(traceback.format_exc())
1521 1521 raise JSONRPCError(
1522 1522 'failed to delete repository `%s`' % (repo.repo_name,)
1523 1523 )
1524 1524
1525 1525 @HasPermissionAnyDecorator('hg.admin')
1526 1526 def grant_user_permission(self, repoid, userid, perm):
1527 1527 """
1528 1528 Grant permission for user on given repository, or update existing one
1529 1529 if found. This command can be executed only using api_key belonging to user
1530 1530 with admin rights.
1531 1531
1532 1532 :param repoid: repository name or repository id
1533 1533 :type repoid: str or int
1534 1534 :param userid:
1535 1535 :param perm: (repository.(none|read|write|admin))
1536 1536 :type perm: str
1537 1537
1538 1538 OUTPUT::
1539 1539
1540 1540 id : <id_given_in_input>
1541 1541 result: {
1542 1542 "msg" : "Granted perm: `<perm>` for user: `<username>` in repo: `<reponame>`",
1543 1543 "success": true
1544 1544 }
1545 1545 error: null
1546 1546 """
1547 1547 repo = get_repo_or_error(repoid)
1548 1548 user = get_user_or_error(userid)
1549 1549 perm = get_perm_or_error(perm)
1550 1550
1551 1551 try:
1552 1552
1553 1553 RepoModel().grant_user_permission(repo=repo, user=user, perm=perm)
1554 1554
1555 1555 Session().commit()
1556 1556 return dict(
1557 1557 msg='Granted perm: `%s` for user: `%s` in repo: `%s`' % (
1558 1558 perm.permission_name, user.username, repo.repo_name
1559 1559 ),
1560 1560 success=True
1561 1561 )
1562 1562 except Exception:
1563 1563 log.error(traceback.format_exc())
1564 1564 raise JSONRPCError(
1565 1565 'failed to edit permission for user: `%s` in repo: `%s`' % (
1566 1566 userid, repoid
1567 1567 )
1568 1568 )
1569 1569
1570 1570 @HasPermissionAnyDecorator('hg.admin')
1571 1571 def revoke_user_permission(self, repoid, userid):
1572 1572 """
1573 1573 Revoke permission for user on given repository. This command can be executed
1574 1574 only using api_key belonging to user with admin rights.
1575 1575
1576 1576 :param repoid: repository name or repository id
1577 1577 :type repoid: str or int
1578 1578 :param userid:
1579 1579
1580 1580 OUTPUT::
1581 1581
1582 1582 id : <id_given_in_input>
1583 1583 result: {
1584 1584 "msg" : "Revoked perm for user: `<username>` in repo: `<reponame>`",
1585 1585 "success": true
1586 1586 }
1587 1587 error: null
1588 1588
1589 1589 """
1590 1590
1591 1591 repo = get_repo_or_error(repoid)
1592 1592 user = get_user_or_error(userid)
1593 1593 try:
1594 1594 RepoModel().revoke_user_permission(repo=repo, user=user)
1595 1595 Session().commit()
1596 1596 return dict(
1597 1597 msg='Revoked perm for user: `%s` in repo: `%s`' % (
1598 1598 user.username, repo.repo_name
1599 1599 ),
1600 1600 success=True
1601 1601 )
1602 1602 except Exception:
1603 1603 log.error(traceback.format_exc())
1604 1604 raise JSONRPCError(
1605 1605 'failed to edit permission for user: `%s` in repo: `%s`' % (
1606 1606 userid, repoid
1607 1607 )
1608 1608 )
1609 1609
1610 1610 # permission check inside
1611 1611 def grant_user_group_permission(self, repoid, usergroupid, perm):
1612 1612 """
1613 1613 Grant permission for user group on given repository, or update
1614 1614 existing one if found. This command can be executed only using
1615 1615 api_key belonging to user with admin rights.
1616 1616
1617 1617 :param repoid: repository name or repository id
1618 1618 :type repoid: str or int
1619 1619 :param usergroupid: id of usergroup
1620 1620 :type usergroupid: str or int
1621 1621 :param perm: (repository.(none|read|write|admin))
1622 1622 :type perm: str
1623 1623
1624 1624 OUTPUT::
1625 1625
1626 1626 id : <id_given_in_input>
1627 1627 result : {
1628 1628 "msg" : "Granted perm: `<perm>` for group: `<usersgroupname>` in repo: `<reponame>`",
1629 1629 "success": true
1630 1630
1631 1631 }
1632 1632 error : null
1633 1633
1634 1634 ERROR OUTPUT::
1635 1635
1636 1636 id : <id_given_in_input>
1637 1637 result : null
1638 1638 error : {
1639 1639 "failed to edit permission for user group: `<usergroup>` in repo `<repo>`'
1640 1640 }
1641 1641
1642 1642 """
1643 1643 repo = get_repo_or_error(repoid)
1644 1644 perm = get_perm_or_error(perm)
1645 1645 user_group = get_user_group_or_error(usergroupid)
1646 1646 if not HasPermissionAny('hg.admin')():
1647 1647 if not HasRepoPermissionLevel('admin')(repo.repo_name):
1648 1648 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
1649 1649
1650 1650 if not HasUserGroupPermissionLevel('read')(user_group.users_group_name):
1651 1651 raise JSONRPCError('user group `%s` does not exist' % (usergroupid,))
1652 1652
1653 1653 try:
1654 1654 RepoModel().grant_user_group_permission(
1655 1655 repo=repo, group_name=user_group, perm=perm)
1656 1656
1657 1657 Session().commit()
1658 1658 return dict(
1659 1659 msg='Granted perm: `%s` for user group: `%s` in '
1660 1660 'repo: `%s`' % (
1661 1661 perm.permission_name, user_group.users_group_name,
1662 1662 repo.repo_name
1663 1663 ),
1664 1664 success=True
1665 1665 )
1666 1666 except Exception:
1667 1667 log.error(traceback.format_exc())
1668 1668 raise JSONRPCError(
1669 1669 'failed to edit permission for user group: `%s` in '
1670 1670 'repo: `%s`' % (
1671 1671 usergroupid, repo.repo_name
1672 1672 )
1673 1673 )
1674 1674
1675 1675 # permission check inside
1676 1676 def revoke_user_group_permission(self, repoid, usergroupid):
1677 1677 """
1678 1678 Revoke permission for user group on given repository. This command can be
1679 1679 executed only using api_key belonging to user with admin rights.
1680 1680
1681 1681 :param repoid: repository name or repository id
1682 1682 :type repoid: str or int
1683 1683 :param usergroupid:
1684 1684
1685 1685 OUTPUT::
1686 1686
1687 1687 id : <id_given_in_input>
1688 1688 result: {
1689 1689 "msg" : "Revoked perm for group: `<usersgroupname>` in repo: `<reponame>`",
1690 1690 "success": true
1691 1691 }
1692 1692 error: null
1693 1693 """
1694 1694 repo = get_repo_or_error(repoid)
1695 1695 user_group = get_user_group_or_error(usergroupid)
1696 1696 if not HasPermissionAny('hg.admin')():
1697 1697 if not HasRepoPermissionLevel('admin')(repo.repo_name):
1698 1698 raise JSONRPCError('repository `%s` does not exist' % (repoid,))
1699 1699
1700 1700 if not HasUserGroupPermissionLevel('read')(user_group.users_group_name):
1701 1701 raise JSONRPCError('user group `%s` does not exist' % (usergroupid,))
1702 1702
1703 1703 try:
1704 1704 RepoModel().revoke_user_group_permission(
1705 1705 repo=repo, group_name=user_group)
1706 1706
1707 1707 Session().commit()
1708 1708 return dict(
1709 1709 msg='Revoked perm for user group: `%s` in repo: `%s`' % (
1710 1710 user_group.users_group_name, repo.repo_name
1711 1711 ),
1712 1712 success=True
1713 1713 )
1714 1714 except Exception:
1715 1715 log.error(traceback.format_exc())
1716 1716 raise JSONRPCError(
1717 1717 'failed to edit permission for user group: `%s` in '
1718 1718 'repo: `%s`' % (
1719 1719 user_group.users_group_name, repo.repo_name
1720 1720 )
1721 1721 )
1722 1722
1723 1723 @HasPermissionAnyDecorator('hg.admin')
1724 1724 def get_repo_group(self, repogroupid):
1725 1725 """
1726 1726 Returns given repo group together with permissions, and repositories
1727 1727 inside the group
1728 1728
1729 1729 :param repogroupid: id/name of repository group
1730 1730 :type repogroupid: str or int
1731 1731 """
1732 1732 repo_group = get_repo_group_or_error(repogroupid)
1733 1733
1734 1734 members = []
1735 1735 for user in repo_group.repo_group_to_perm:
1736 1736 perm = user.permission.permission_name
1737 1737 user = user.user
1738 1738 user_data = {
1739 1739 'name': user.username,
1740 1740 'type': "user",
1741 1741 'permission': perm
1742 1742 }
1743 1743 members.append(user_data)
1744 1744
1745 1745 for user_group in repo_group.users_group_to_perm:
1746 1746 perm = user_group.permission.permission_name
1747 1747 user_group = user_group.users_group
1748 1748 user_group_data = {
1749 1749 'name': user_group.users_group_name,
1750 1750 'type': "user_group",
1751 1751 'permission': perm
1752 1752 }
1753 1753 members.append(user_group_data)
1754 1754
1755 1755 data = repo_group.get_api_data()
1756 1756 data["members"] = members
1757 1757 return data
1758 1758
1759 1759 @HasPermissionAnyDecorator('hg.admin')
1760 1760 def get_repo_groups(self):
1761 1761 """
1762 1762 Returns all repository groups
1763 1763
1764 1764 """
1765 1765 return [
1766 1766 repo_group.get_api_data()
1767 1767 for repo_group in RepoGroup.query()
1768 1768 ]
1769 1769
1770 1770 @HasPermissionAnyDecorator('hg.admin')
1771 1771 def create_repo_group(self, group_name, description=Optional(''),
1772 1772 owner=Optional(OAttr('apiuser')),
1773 1773 parent=Optional(None),
1774 1774 copy_permissions=Optional(False)):
1775 1775 """
1776 1776 Creates a repository group. This command can be executed only using
1777 1777 api_key belonging to user with admin rights.
1778 1778
1779 1779 :param group_name:
1780 1780 :type group_name:
1781 1781 :param description:
1782 1782 :type description:
1783 1783 :param owner:
1784 1784 :type owner:
1785 1785 :param parent:
1786 1786 :type parent:
1787 1787 :param copy_permissions:
1788 1788 :type copy_permissions:
1789 1789
1790 1790 OUTPUT::
1791 1791
1792 1792 id : <id_given_in_input>
1793 1793 result : {
1794 1794 "msg": "created new repo group `<repo_group_name>`"
1795 1795 "repo_group": <repogroup_object>
1796 1796 }
1797 1797 error : null
1798 1798
1799 1799 ERROR OUTPUT::
1800 1800
1801 1801 id : <id_given_in_input>
1802 1802 result : null
1803 1803 error : {
1804 1804 failed to create repo group `<repogroupid>`
1805 1805 }
1806 1806
1807 1807 """
1808 1808 if RepoGroup.get_by_group_name(group_name):
1809 1809 raise JSONRPCError("repo group `%s` already exist" % (group_name,))
1810 1810
1811 1811 if isinstance(owner, Optional):
1812 1812 owner = request.authuser.user_id
1813 1813 group_description = Optional.extract(description)
1814 1814 parent_group = Optional.extract(parent)
1815 1815 if not isinstance(parent, Optional):
1816 1816 parent_group = get_repo_group_or_error(parent_group)
1817 1817
1818 1818 copy_permissions = Optional.extract(copy_permissions)
1819 1819 try:
1820 1820 repo_group = RepoGroupModel().create(
1821 1821 group_name=group_name,
1822 1822 group_description=group_description,
1823 1823 owner=owner,
1824 1824 parent=parent_group,
1825 1825 copy_permissions=copy_permissions
1826 1826 )
1827 1827 Session().commit()
1828 1828 return dict(
1829 1829 msg='created new repo group `%s`' % group_name,
1830 1830 repo_group=repo_group.get_api_data()
1831 1831 )
1832 1832 except Exception:
1833 1833
1834 1834 log.error(traceback.format_exc())
1835 1835 raise JSONRPCError('failed to create repo group `%s`' % (group_name,))
1836 1836
1837 1837 @HasPermissionAnyDecorator('hg.admin')
1838 1838 def update_repo_group(self, repogroupid, group_name=Optional(''),
1839 1839 description=Optional(''),
1840 1840 owner=Optional(OAttr('apiuser')),
1841 1841 parent=Optional(None)):
1842 1842 repo_group = get_repo_group_or_error(repogroupid)
1843 1843
1844 1844 updates = {}
1845 1845 try:
1846 1846 store_update(updates, group_name, 'group_name')
1847 1847 store_update(updates, description, 'group_description')
1848 1848 store_update(updates, owner, 'owner')
1849 1849 store_update(updates, parent, 'parent_group')
1850 1850 repo_group = RepoGroupModel().update(repo_group, updates)
1851 1851 Session().commit()
1852 1852 return dict(
1853 1853 msg='updated repository group ID:%s %s' % (repo_group.group_id,
1854 1854 repo_group.group_name),
1855 1855 repo_group=repo_group.get_api_data()
1856 1856 )
1857 1857 except Exception:
1858 1858 log.error(traceback.format_exc())
1859 1859 raise JSONRPCError('failed to update repository group `%s`'
1860 1860 % (repogroupid,))
1861 1861
1862 1862 @HasPermissionAnyDecorator('hg.admin')
1863 1863 def delete_repo_group(self, repogroupid):
1864 1864 """
1865 1865
1866 1866 :param repogroupid: name or id of repository group
1867 1867 :type repogroupid: str or int
1868 1868
1869 1869 OUTPUT::
1870 1870
1871 1871 id : <id_given_in_input>
1872 1872 result : {
1873 1873 'msg': 'deleted repo group ID:<repogroupid> <repogroupname>
1874 1874 'repo_group': null
1875 1875 }
1876 1876 error : null
1877 1877
1878 1878 ERROR OUTPUT::
1879 1879
1880 1880 id : <id_given_in_input>
1881 1881 result : null
1882 1882 error : {
1883 1883 "failed to delete repo group ID:<repogroupid> <repogroupname>"
1884 1884 }
1885 1885
1886 1886 """
1887 1887 repo_group = get_repo_group_or_error(repogroupid)
1888 1888
1889 1889 try:
1890 1890 RepoGroupModel().delete(repo_group)
1891 1891 Session().commit()
1892 1892 return dict(
1893 1893 msg='deleted repo group ID:%s %s' %
1894 1894 (repo_group.group_id, repo_group.group_name),
1895 1895 repo_group=None
1896 1896 )
1897 1897 except Exception:
1898 1898 log.error(traceback.format_exc())
1899 1899 raise JSONRPCError('failed to delete repo group ID:%s %s' %
1900 1900 (repo_group.group_id, repo_group.group_name)
1901 1901 )
1902 1902
1903 1903 # permission check inside
1904 1904 def grant_user_permission_to_repo_group(self, repogroupid, userid,
1905 1905 perm, apply_to_children=Optional('none')):
1906 1906 """
1907 1907 Grant permission for user on given repository group, or update existing
1908 1908 one if found. This command can be executed only using api_key belonging
1909 1909 to user with admin rights, or user who has admin right to given repository
1910 1910 group.
1911 1911
1912 1912 :param repogroupid: name or id of repository group
1913 1913 :type repogroupid: str or int
1914 1914 :param userid:
1915 1915 :param perm: (group.(none|read|write|admin))
1916 1916 :type perm: str
1917 1917 :param apply_to_children: 'none', 'repos', 'groups', 'all'
1918 1918 :type apply_to_children: str
1919 1919
1920 1920 OUTPUT::
1921 1921
1922 1922 id : <id_given_in_input>
1923 1923 result: {
1924 1924 "msg" : "Granted perm: `<perm>` (recursive:<apply_to_children>) for user: `<username>` in repo group: `<repo_group_name>`",
1925 1925 "success": true
1926 1926 }
1927 1927 error: null
1928 1928
1929 1929 ERROR OUTPUT::
1930 1930
1931 1931 id : <id_given_in_input>
1932 1932 result : null
1933 1933 error : {
1934 1934 "failed to edit permission for user: `<userid>` in repo group: `<repo_group_name>`"
1935 1935 }
1936 1936
1937 1937 """
1938 1938
1939 1939 repo_group = get_repo_group_or_error(repogroupid)
1940 1940
1941 1941 if not HasPermissionAny('hg.admin')():
1942 1942 if not HasRepoGroupPermissionLevel('admin')(repo_group.group_name):
1943 1943 raise JSONRPCError('repository group `%s` does not exist' % (repogroupid,))
1944 1944
1945 1945 user = get_user_or_error(userid)
1946 1946 perm = get_perm_or_error(perm, prefix='group.')
1947 1947 apply_to_children = Optional.extract(apply_to_children)
1948 1948
1949 1949 try:
1950 1950 RepoGroupModel().add_permission(repo_group=repo_group,
1951 1951 obj=user,
1952 1952 obj_type="user",
1953 1953 perm=perm,
1954 1954 recursive=apply_to_children)
1955 1955 Session().commit()
1956 1956 return dict(
1957 1957 msg='Granted perm: `%s` (recursive:%s) for user: `%s` in repo group: `%s`' % (
1958 1958 perm.permission_name, apply_to_children, user.username, repo_group.name
1959 1959 ),
1960 1960 success=True
1961 1961 )
1962 1962 except Exception:
1963 1963 log.error(traceback.format_exc())
1964 1964 raise JSONRPCError(
1965 1965 'failed to edit permission for user: `%s` in repo group: `%s`' % (
1966 1966 userid, repo_group.name))
1967 1967
1968 1968 # permission check inside
1969 1969 def revoke_user_permission_from_repo_group(self, repogroupid, userid,
1970 1970 apply_to_children=Optional('none')):
1971 1971 """
1972 1972 Revoke permission for user on given repository group. This command can
1973 1973 be executed only using api_key belonging to user with admin rights, or
1974 1974 user who has admin right to given repository group.
1975 1975
1976 1976 :param repogroupid: name or id of repository group
1977 1977 :type repogroupid: str or int
1978 1978 :param userid:
1979 1979 :type userid:
1980 1980 :param apply_to_children: 'none', 'repos', 'groups', 'all'
1981 1981 :type apply_to_children: str
1982 1982
1983 1983 OUTPUT::
1984 1984
1985 1985 id : <id_given_in_input>
1986 1986 result: {
1987 1987 "msg" : "Revoked perm (recursive:<apply_to_children>) for user: `<username>` in repo group: `<repo_group_name>`",
1988 1988 "success": true
1989 1989 }
1990 1990 error: null
1991 1991
1992 1992 ERROR OUTPUT::
1993 1993
1994 1994 id : <id_given_in_input>
1995 1995 result : null
1996 1996 error : {
1997 1997 "failed to edit permission for user: `<userid>` in repo group: `<repo_group_name>`"
1998 1998 }
1999 1999
2000 2000 """
2001 2001
2002 2002 repo_group = get_repo_group_or_error(repogroupid)
2003 2003
2004 2004 if not HasPermissionAny('hg.admin')():
2005 2005 if not HasRepoGroupPermissionLevel('admin')(repo_group.group_name):
2006 2006 raise JSONRPCError('repository group `%s` does not exist' % (repogroupid,))
2007 2007
2008 2008 user = get_user_or_error(userid)
2009 2009 apply_to_children = Optional.extract(apply_to_children)
2010 2010
2011 2011 try:
2012 2012 RepoGroupModel().delete_permission(repo_group=repo_group,
2013 2013 obj=user,
2014 2014 obj_type="user",
2015 2015 recursive=apply_to_children)
2016 2016
2017 2017 Session().commit()
2018 2018 return dict(
2019 2019 msg='Revoked perm (recursive:%s) for user: `%s` in repo group: `%s`' % (
2020 2020 apply_to_children, user.username, repo_group.name
2021 2021 ),
2022 2022 success=True
2023 2023 )
2024 2024 except Exception:
2025 2025 log.error(traceback.format_exc())
2026 2026 raise JSONRPCError(
2027 2027 'failed to edit permission for user: `%s` in repo group: `%s`' % (
2028 2028 userid, repo_group.name))
2029 2029
2030 2030 # permission check inside
2031 2031 def grant_user_group_permission_to_repo_group(
2032 2032 self, repogroupid, usergroupid, perm,
2033 2033 apply_to_children=Optional('none')):
2034 2034 """
2035 2035 Grant permission for user group on given repository group, or update
2036 2036 existing one if found. This command can be executed only using
2037 2037 api_key belonging to user with admin rights, or user who has admin
2038 2038 right to given repository group.
2039 2039
2040 2040 :param repogroupid: name or id of repository group
2041 2041 :type repogroupid: str or int
2042 2042 :param usergroupid: id of usergroup
2043 2043 :type usergroupid: str or int
2044 2044 :param perm: (group.(none|read|write|admin))
2045 2045 :type perm: str
2046 2046 :param apply_to_children: 'none', 'repos', 'groups', 'all'
2047 2047 :type apply_to_children: str
2048 2048
2049 2049 OUTPUT::
2050 2050
2051 2051 id : <id_given_in_input>
2052 2052 result : {
2053 2053 "msg" : "Granted perm: `<perm>` (recursive:<apply_to_children>) for user group: `<usersgroupname>` in repo group: `<repo_group_name>`",
2054 2054 "success": true
2055 2055
2056 2056 }
2057 2057 error : null
2058 2058
2059 2059 ERROR OUTPUT::
2060 2060
2061 2061 id : <id_given_in_input>
2062 2062 result : null
2063 2063 error : {
2064 2064 "failed to edit permission for user group: `<usergroup>` in repo group: `<repo_group_name>`"
2065 2065 }
2066 2066
2067 2067 """
2068 2068 repo_group = get_repo_group_or_error(repogroupid)
2069 2069 perm = get_perm_or_error(perm, prefix='group.')
2070 2070 user_group = get_user_group_or_error(usergroupid)
2071 2071 if not HasPermissionAny('hg.admin')():
2072 2072 if not HasRepoGroupPermissionLevel('admin')(repo_group.group_name):
2073 2073 raise JSONRPCError(
2074 2074 'repository group `%s` does not exist' % (repogroupid,))
2075 2075
2076 2076 if not HasUserGroupPermissionLevel('read')(user_group.users_group_name):
2077 2077 raise JSONRPCError(
2078 2078 'user group `%s` does not exist' % (usergroupid,))
2079 2079
2080 2080 apply_to_children = Optional.extract(apply_to_children)
2081 2081
2082 2082 try:
2083 2083 RepoGroupModel().add_permission(repo_group=repo_group,
2084 2084 obj=user_group,
2085 2085 obj_type="user_group",
2086 2086 perm=perm,
2087 2087 recursive=apply_to_children)
2088 2088 Session().commit()
2089 2089 return dict(
2090 2090 msg='Granted perm: `%s` (recursive:%s) for user group: `%s` in repo group: `%s`' % (
2091 2091 perm.permission_name, apply_to_children,
2092 2092 user_group.users_group_name, repo_group.name
2093 2093 ),
2094 2094 success=True
2095 2095 )
2096 2096 except Exception:
2097 2097 log.error(traceback.format_exc())
2098 2098 raise JSONRPCError(
2099 2099 'failed to edit permission for user group: `%s` in '
2100 2100 'repo group: `%s`' % (
2101 2101 usergroupid, repo_group.name
2102 2102 )
2103 2103 )
2104 2104
2105 2105 # permission check inside
2106 2106 def revoke_user_group_permission_from_repo_group(
2107 2107 self, repogroupid, usergroupid,
2108 2108 apply_to_children=Optional('none')):
2109 2109 """
2110 2110 Revoke permission for user group on given repository. This command can be
2111 2111 executed only using api_key belonging to user with admin rights, or
2112 2112 user who has admin right to given repository group.
2113 2113
2114 2114 :param repogroupid: name or id of repository group
2115 2115 :type repogroupid: str or int
2116 2116 :param usergroupid:
2117 2117 :param apply_to_children: 'none', 'repos', 'groups', 'all'
2118 2118 :type apply_to_children: str
2119 2119
2120 2120 OUTPUT::
2121 2121
2122 2122 id : <id_given_in_input>
2123 2123 result: {
2124 2124 "msg" : "Revoked perm (recursive:<apply_to_children>) for user group: `<usersgroupname>` in repo group: `<repo_group_name>`",
2125 2125 "success": true
2126 2126 }
2127 2127 error: null
2128 2128
2129 2129 ERROR OUTPUT::
2130 2130
2131 2131 id : <id_given_in_input>
2132 2132 result : null
2133 2133 error : {
2134 2134 "failed to edit permission for user group: `<usergroup>` in repo group: `<repo_group_name>`"
2135 2135 }
2136 2136
2137 2137
2138 2138 """
2139 2139 repo_group = get_repo_group_or_error(repogroupid)
2140 2140 user_group = get_user_group_or_error(usergroupid)
2141 2141 if not HasPermissionAny('hg.admin')():
2142 2142 if not HasRepoGroupPermissionLevel('admin')(repo_group.group_name):
2143 2143 raise JSONRPCError(
2144 2144 'repository group `%s` does not exist' % (repogroupid,))
2145 2145
2146 2146 if not HasUserGroupPermissionLevel('read')(user_group.users_group_name):
2147 2147 raise JSONRPCError(
2148 2148 'user group `%s` does not exist' % (usergroupid,))
2149 2149
2150 2150 apply_to_children = Optional.extract(apply_to_children)
2151 2151
2152 2152 try:
2153 2153 RepoGroupModel().delete_permission(repo_group=repo_group,
2154 2154 obj=user_group,
2155 2155 obj_type="user_group",
2156 2156 recursive=apply_to_children)
2157 2157 Session().commit()
2158 2158 return dict(
2159 2159 msg='Revoked perm (recursive:%s) for user group: `%s` in repo group: `%s`' % (
2160 2160 apply_to_children, user_group.users_group_name, repo_group.name
2161 2161 ),
2162 2162 success=True
2163 2163 )
2164 2164 except Exception:
2165 2165 log.error(traceback.format_exc())
2166 2166 raise JSONRPCError(
2167 2167 'failed to edit permission for user group: `%s` in repo group: `%s`' % (
2168 2168 user_group.users_group_name, repo_group.name
2169 2169 )
2170 2170 )
2171 2171
2172 2172 def get_gist(self, gistid):
2173 2173 """
2174 2174 Get given gist by id
2175 2175
2176 2176 :param gistid: id of private or public gist
2177 2177 :type gistid: str
2178 2178 """
2179 2179 gist = get_gist_or_error(gistid)
2180 2180 if not HasPermissionAny('hg.admin')():
2181 2181 if gist.owner_id != request.authuser.user_id:
2182 2182 raise JSONRPCError('gist `%s` does not exist' % (gistid,))
2183 2183 return gist.get_api_data()
2184 2184
2185 2185 def get_gists(self, userid=Optional(OAttr('apiuser'))):
2186 2186 """
2187 2187 Get all gists for given user. If userid is empty returned gists
2188 2188 are for user who called the api
2189 2189
2190 2190 :param userid: user to get gists for
2191 2191 :type userid: Optional(str or int)
2192 2192 """
2193 2193 if not HasPermissionAny('hg.admin')():
2194 2194 # make sure normal user does not pass someone else userid,
2195 2195 # he is not allowed to do that
2196 2196 if not isinstance(userid, Optional) and userid != request.authuser.user_id:
2197 2197 raise JSONRPCError(
2198 2198 'userid is not the same as your user'
2199 2199 )
2200 2200
2201 2201 if isinstance(userid, Optional):
2202 2202 user_id = request.authuser.user_id
2203 2203 else:
2204 2204 user_id = get_user_or_error(userid).user_id
2205 2205
2206 2206 return [
2207 2207 gist.get_api_data()
2208 2208 for gist in Gist().query()
2209 2209 .filter_by(is_expired=False)
2210 2210 .filter(Gist.owner_id == user_id)
2211 2211 .order_by(Gist.created_on.desc())
2212 2212 ]
2213 2213
2214 2214 def create_gist(self, files, owner=Optional(OAttr('apiuser')),
2215 2215 gist_type=Optional(Gist.GIST_PUBLIC), lifetime=Optional(-1),
2216 2216 description=Optional('')):
2217 2217
2218 2218 """
2219 2219 Creates new Gist
2220 2220
2221 2221 :param files: files to be added to gist
2222 2222 {'filename': {'content':'...', 'lexer': null},
2223 2223 'filename2': {'content':'...', 'lexer': null}}
2224 2224 :type files: dict
2225 2225 :param owner: gist owner, defaults to api method caller
2226 2226 :type owner: Optional(str or int)
2227 2227 :param gist_type: type of gist 'public' or 'private'
2228 2228 :type gist_type: Optional(str)
2229 2229 :param lifetime: time in minutes of gist lifetime
2230 2230 :type lifetime: Optional(int)
2231 2231 :param description: gist description
2232 2232 :type description: Optional(str)
2233 2233
2234 2234 OUTPUT::
2235 2235
2236 2236 id : <id_given_in_input>
2237 2237 result : {
2238 2238 "msg": "created new gist",
2239 2239 "gist": {}
2240 2240 }
2241 2241 error : null
2242 2242
2243 2243 ERROR OUTPUT::
2244 2244
2245 2245 id : <id_given_in_input>
2246 2246 result : null
2247 2247 error : {
2248 2248 "failed to create gist"
2249 2249 }
2250 2250
2251 2251 """
2252 2252 try:
2253 2253 if isinstance(owner, Optional):
2254 2254 owner = request.authuser.user_id
2255 2255
2256 2256 owner = get_user_or_error(owner)
2257 2257 description = Optional.extract(description)
2258 2258 gist_type = Optional.extract(gist_type)
2259 2259 lifetime = Optional.extract(lifetime)
2260 2260
2261 2261 gist = GistModel().create(description=description,
2262 2262 owner=owner,
2263 2263 ip_addr=request.ip_addr,
2264 2264 gist_mapping=files,
2265 2265 gist_type=gist_type,
2266 2266 lifetime=lifetime)
2267 2267 Session().commit()
2268 2268 return dict(
2269 2269 msg='created new gist',
2270 2270 gist=gist.get_api_data()
2271 2271 )
2272 2272 except Exception:
2273 2273 log.error(traceback.format_exc())
2274 2274 raise JSONRPCError('failed to create gist')
2275 2275
2276 2276 # def update_gist(self, gistid, files, owner=Optional(OAttr('apiuser')),
2277 2277 # gist_type=Optional(Gist.GIST_PUBLIC),
2278 2278 # gist_lifetime=Optional(-1), gist_description=Optional('')):
2279 2279 # gist = get_gist_or_error(gistid)
2280 2280 # updates = {}
2281 2281
2282 2282 # permission check inside
2283 2283 def delete_gist(self, gistid):
2284 2284 """
2285 2285 Deletes existing gist
2286 2286
2287 2287 :param gistid: id of gist to delete
2288 2288 :type gistid: str
2289 2289
2290 2290 OUTPUT::
2291 2291
2292 2292 id : <id_given_in_input>
2293 2293 result : {
2294 2294 "deleted gist ID: <gist_id>",
2295 2295 "gist": null
2296 2296 }
2297 2297 error : null
2298 2298
2299 2299 ERROR OUTPUT::
2300 2300
2301 2301 id : <id_given_in_input>
2302 2302 result : null
2303 2303 error : {
2304 2304 "failed to delete gist ID:<gist_id>"
2305 2305 }
2306 2306
2307 2307 """
2308 2308 gist = get_gist_or_error(gistid)
2309 2309 if not HasPermissionAny('hg.admin')():
2310 2310 if gist.owner_id != request.authuser.user_id:
2311 2311 raise JSONRPCError('gist `%s` does not exist' % (gistid,))
2312 2312
2313 2313 try:
2314 2314 GistModel().delete(gist)
2315 2315 Session().commit()
2316 2316 return dict(
2317 2317 msg='deleted gist ID:%s' % (gist.gist_access_id,),
2318 2318 gist=None
2319 2319 )
2320 2320 except Exception:
2321 2321 log.error(traceback.format_exc())
2322 2322 raise JSONRPCError('failed to delete gist ID:%s'
2323 2323 % (gist.gist_access_id,))
2324 2324
2325 2325 # permission check inside
2326 2326 def get_changesets(self, repoid, start=None, end=None, start_date=None,
2327 2327 end_date=None, branch_name=None, reverse=False, with_file_list=False, max_revisions=None):
2328 2328 repo = get_repo_or_error(repoid)
2329 2329 if not HasRepoPermissionLevel('read')(repo.repo_name):
2330 2330 raise JSONRPCError('Access denied to repo %s' % repo.repo_name)
2331 2331
2332 2332 format = "%Y-%m-%dT%H:%M:%S"
2333 2333 try:
2334 2334 return [e.__json__(with_file_list) for e in
2335 2335 repo.scm_instance.get_changesets(start,
2336 2336 end,
2337 2337 datetime.strptime(start_date, format) if start_date else None,
2338 2338 datetime.strptime(end_date, format) if end_date else None,
2339 2339 branch_name,
2340 2340 reverse, max_revisions)]
2341 2341 except EmptyRepositoryError as e:
2342 2342 raise JSONRPCError('Repository is empty')
2343 2343
2344 2344 # permission check inside
2345 2345 def get_changeset(self, repoid, raw_id, with_reviews=Optional(False)):
2346 2346 repo = get_repo_or_error(repoid)
2347 2347 if not HasRepoPermissionLevel('read')(repo.repo_name):
2348 2348 raise JSONRPCError('Access denied to repo %s' % repo.repo_name)
2349 2349 changeset = repo.get_changeset(raw_id)
2350 2350 if isinstance(changeset, EmptyChangeset):
2351 2351 raise JSONRPCError('Changeset %s does not exist' % raw_id)
2352 2352
2353 2353 info = dict(changeset.as_dict())
2354 2354
2355 2355 with_reviews = Optional.extract(with_reviews)
2356 2356 if with_reviews:
2357 2357 reviews = ChangesetStatusModel().get_statuses(
2358 2358 repo.repo_name, raw_id)
2359 2359 info["reviews"] = reviews
2360 2360
2361 2361 return info
2362 2362
2363 2363 # permission check inside
2364 2364 def get_pullrequest(self, pullrequest_id):
2365 2365 """
2366 2366 Get given pull request by id
2367 2367 """
2368 2368 pull_request = PullRequest.get(pullrequest_id)
2369 2369 if pull_request is None:
2370 2370 raise JSONRPCError('pull request `%s` does not exist' % (pullrequest_id,))
2371 2371 if not HasRepoPermissionLevel('read')(pull_request.org_repo.repo_name):
2372 2372 raise JSONRPCError('not allowed')
2373 2373 return pull_request.get_api_data()
2374 2374
2375 2375 # permission check inside
2376 2376 def comment_pullrequest(self, pull_request_id, comment_msg='', status=None, close_pr=False):
2377 2377 """
2378 2378 Add comment, close and change status of pull request.
2379 2379 """
2380 2380 apiuser = get_user_or_error(request.authuser.user_id)
2381 2381 pull_request = PullRequest.get(pull_request_id)
2382 2382 if pull_request is None:
2383 2383 raise JSONRPCError('pull request `%s` does not exist' % (pull_request_id,))
2384 2384 if (not HasRepoPermissionLevel('read')(pull_request.org_repo.repo_name)):
2385 2385 raise JSONRPCError('No permission to add comment. User needs at least reading permissions'
2386 2386 ' to the source repository.')
2387 2387 owner = apiuser.user_id == pull_request.owner_id
2388 2388 reviewer = apiuser.user_id in [reviewer.user_id for reviewer in pull_request.reviewers]
2389 2389 if close_pr and not (apiuser.admin or owner):
2390 2390 raise JSONRPCError('No permission to close pull request. User needs to be admin or owner.')
2391 2391 if status and not (apiuser.admin or owner or reviewer):
2392 2392 raise JSONRPCError('No permission to change pull request status. User needs to be admin, owner or reviewer.')
2393 2393 if pull_request.is_closed():
2394 2394 raise JSONRPCError('pull request is already closed')
2395 2395
2396 2396 comment = ChangesetCommentsModel().create(
2397 2397 text=comment_msg,
2398 2398 repo=pull_request.org_repo.repo_id,
2399 2399 author=apiuser.user_id,
2400 2400 pull_request=pull_request.pull_request_id,
2401 2401 f_path=None,
2402 2402 line_no=None,
2403 2403 status_change=ChangesetStatus.get_status_lbl(status),
2404 2404 closing_pr=close_pr
2405 2405 )
2406 2406 action_logger(apiuser,
2407 2407 'user_commented_pull_request:%s' % pull_request_id,
2408 2408 pull_request.org_repo, request.ip_addr)
2409 2409 if status:
2410 2410 ChangesetStatusModel().set_status(
2411 2411 pull_request.org_repo_id,
2412 2412 status,
2413 2413 apiuser.user_id,
2414 2414 comment,
2415 2415 pull_request=pull_request_id
2416 2416 )
2417 2417 if close_pr:
2418 2418 PullRequestModel().close_pull_request(pull_request_id)
2419 2419 action_logger(apiuser,
2420 2420 'user_closed_pull_request:%s' % pull_request_id,
2421 2421 pull_request.org_repo, request.ip_addr)
2422 2422 Session().commit()
2423 2423 return True
@@ -1,754 +1,754 b''
1 1 # -*- coding: utf-8 -*-
2 2 # This program is free software: you can redistribute it and/or modify
3 3 # it under the terms of the GNU General Public License as published by
4 4 # the Free Software Foundation, either version 3 of the License, or
5 5 # (at your option) any later version.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 """
15 15 kallithea.controllers.files
16 16 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
17 17
18 18 Files controller for Kallithea
19 19
20 20 This file was forked by the Kallithea project in July 2014.
21 21 Original author and date, and relevant copyright and licensing information is below:
22 22 :created_on: Apr 21, 2010
23 23 :author: marcink
24 24 :copyright: (c) 2013 RhodeCode GmbH, and others.
25 25 :license: GPLv3, see LICENSE.md for more details.
26 26 """
27 27
28 28 import logging
29 29 import os
30 30 import posixpath
31 31 import shutil
32 32 import tempfile
33 33 import traceback
34 34 from collections import OrderedDict
35 35
36 36 from tg import request, response
37 37 from tg import tmpl_context as c
38 38 from tg.i18n import ugettext as _
39 39 from webob.exc import HTTPFound, HTTPNotFound
40 40
41 41 from kallithea.config.routing import url
42 42 from kallithea.controllers.changeset import _context_url, _ignorews_url, anchor_url, get_ignore_ws, get_line_ctx
43 43 from kallithea.lib import diffs
44 44 from kallithea.lib import helpers as h
45 45 from kallithea.lib.auth import HasRepoPermissionLevelDecorator, LoginRequired
46 46 from kallithea.lib.base import BaseRepoController, jsonify, render
47 47 from kallithea.lib.exceptions import NonRelativePathError
48 48 from kallithea.lib.utils import action_logger
49 49 from kallithea.lib.utils2 import convert_line_endings, detect_mode, safe_int, safe_str, str2bool
50 50 from kallithea.lib.vcs.backends.base import EmptyChangeset
51 51 from kallithea.lib.vcs.conf import settings
52 from kallithea.lib.vcs.exceptions import (
53 ChangesetDoesNotExistError, ChangesetError, EmptyRepositoryError, ImproperArchiveTypeError, NodeAlreadyExistsError, NodeDoesNotExistError, NodeError, RepositoryError, VCSError)
52 from kallithea.lib.vcs.exceptions import (ChangesetDoesNotExistError, ChangesetError, EmptyRepositoryError, ImproperArchiveTypeError, NodeAlreadyExistsError,
53 NodeDoesNotExistError, NodeError, RepositoryError, VCSError)
54 54 from kallithea.lib.vcs.nodes import FileNode
55 55 from kallithea.model.db import Repository
56 56 from kallithea.model.repo import RepoModel
57 57 from kallithea.model.scm import ScmModel
58 58
59 59
60 60 log = logging.getLogger(__name__)
61 61
62 62
63 63 class FilesController(BaseRepoController):
64 64
65 65 def _before(self, *args, **kwargs):
66 66 super(FilesController, self)._before(*args, **kwargs)
67 67
68 68 def __get_cs(self, rev, silent_empty=False):
69 69 """
70 70 Safe way to get changeset if error occur it redirects to tip with
71 71 proper message
72 72
73 73 :param rev: revision to fetch
74 74 :silent_empty: return None if repository is empty
75 75 """
76 76
77 77 try:
78 78 return c.db_repo_scm_instance.get_changeset(rev)
79 79 except EmptyRepositoryError as e:
80 80 if silent_empty:
81 81 return None
82 82 url_ = url('files_add_home',
83 83 repo_name=c.repo_name,
84 84 revision=0, f_path='', anchor='edit')
85 85 add_new = h.link_to(_('Click here to add new file'), url_, class_="alert-link")
86 86 h.flash(_('There are no files yet.') + ' ' + add_new, category='warning')
87 87 raise HTTPNotFound()
88 88 except (ChangesetDoesNotExistError, LookupError):
89 89 msg = _('Such revision does not exist for this repository')
90 90 h.flash(msg, category='error')
91 91 raise HTTPNotFound()
92 92 except RepositoryError as e:
93 93 h.flash(e, category='error')
94 94 raise HTTPNotFound()
95 95
96 96 def __get_filenode(self, cs, path):
97 97 """
98 98 Returns file_node or raise HTTP error.
99 99
100 100 :param cs: given changeset
101 101 :param path: path to lookup
102 102 """
103 103
104 104 try:
105 105 file_node = cs.get_node(path)
106 106 if file_node.is_dir():
107 107 raise RepositoryError('given path is a directory')
108 108 except ChangesetDoesNotExistError:
109 109 msg = _('Such revision does not exist for this repository')
110 110 h.flash(msg, category='error')
111 111 raise HTTPNotFound()
112 112 except RepositoryError as e:
113 113 h.flash(e, category='error')
114 114 raise HTTPNotFound()
115 115
116 116 return file_node
117 117
118 118 @LoginRequired(allow_default_user=True)
119 119 @HasRepoPermissionLevelDecorator('read')
120 120 def index(self, repo_name, revision, f_path, annotate=False):
121 121 # redirect to given revision from form if given
122 122 post_revision = request.POST.get('at_rev', None)
123 123 if post_revision:
124 124 cs = self.__get_cs(post_revision) # FIXME - unused!
125 125
126 126 c.revision = revision
127 127 c.changeset = self.__get_cs(revision)
128 128 c.branch = request.GET.get('branch', None)
129 129 c.f_path = f_path
130 130 c.annotate = annotate
131 131 cur_rev = c.changeset.revision
132 132 # used in files_source.html:
133 133 c.cut_off_limit = self.cut_off_limit
134 134 c.fulldiff = request.GET.get('fulldiff')
135 135
136 136 # prev link
137 137 try:
138 138 prev_rev = c.db_repo_scm_instance.get_changeset(cur_rev).prev(c.branch)
139 139 c.url_prev = url('files_home', repo_name=c.repo_name,
140 140 revision=prev_rev.raw_id, f_path=f_path)
141 141 if c.branch:
142 142 c.url_prev += '?branch=%s' % c.branch
143 143 except (ChangesetDoesNotExistError, VCSError):
144 144 c.url_prev = '#'
145 145
146 146 # next link
147 147 try:
148 148 next_rev = c.db_repo_scm_instance.get_changeset(cur_rev).next(c.branch)
149 149 c.url_next = url('files_home', repo_name=c.repo_name,
150 150 revision=next_rev.raw_id, f_path=f_path)
151 151 if c.branch:
152 152 c.url_next += '?branch=%s' % c.branch
153 153 except (ChangesetDoesNotExistError, VCSError):
154 154 c.url_next = '#'
155 155
156 156 # files or dirs
157 157 try:
158 158 c.file = c.changeset.get_node(f_path)
159 159
160 160 if c.file.is_submodule():
161 161 raise HTTPFound(location=c.file.url)
162 162 elif c.file.is_file():
163 163 c.load_full_history = False
164 164 # determine if we're on branch head
165 165 _branches = c.db_repo_scm_instance.branches
166 166 c.on_branch_head = revision in _branches or revision in _branches.values()
167 167 _hist = []
168 168 c.file_history = []
169 169 if c.load_full_history:
170 170 c.file_history, _hist = self._get_node_history(c.changeset, f_path)
171 171
172 172 c.authors = []
173 173 for a in set([x.author for x in _hist]):
174 174 c.authors.append((h.email(a), h.person(a)))
175 175 else:
176 176 c.authors = c.file_history = []
177 177 except RepositoryError as e:
178 178 h.flash(e, category='error')
179 179 raise HTTPNotFound()
180 180
181 181 if request.environ.get('HTTP_X_PARTIAL_XHR'):
182 182 return render('files/files_ypjax.html')
183 183
184 184 # TODO: tags and bookmarks?
185 185 c.revision_options = [(c.changeset.raw_id,
186 186 _('%s at %s') % (b, h.short_id(c.changeset.raw_id))) for b in c.changeset.branches] + \
187 187 [(n, b) for b, n in c.db_repo_scm_instance.branches.items()]
188 188 if c.db_repo_scm_instance.closed_branches:
189 189 prefix = _('(closed)') + ' '
190 190 c.revision_options += [('-', '-')] + \
191 191 [(n, prefix + b) for b, n in c.db_repo_scm_instance.closed_branches.items()]
192 192
193 193 return render('files/files.html')
194 194
195 195 @LoginRequired(allow_default_user=True)
196 196 @HasRepoPermissionLevelDecorator('read')
197 197 @jsonify
198 198 def history(self, repo_name, revision, f_path):
199 199 changeset = self.__get_cs(revision)
200 200 _file = changeset.get_node(f_path)
201 201 if _file.is_file():
202 202 file_history, _hist = self._get_node_history(changeset, f_path)
203 203
204 204 res = []
205 205 for obj in file_history:
206 206 res.append({
207 207 'text': obj[1],
208 208 'children': [{'id': o[0], 'text': o[1]} for o in obj[0]]
209 209 })
210 210
211 211 data = {
212 212 'more': False,
213 213 'results': res
214 214 }
215 215 return data
216 216
217 217 @LoginRequired(allow_default_user=True)
218 218 @HasRepoPermissionLevelDecorator('read')
219 219 def authors(self, repo_name, revision, f_path):
220 220 changeset = self.__get_cs(revision)
221 221 _file = changeset.get_node(f_path)
222 222 if _file.is_file():
223 223 file_history, _hist = self._get_node_history(changeset, f_path)
224 224 c.authors = []
225 225 for a in set([x.author for x in _hist]):
226 226 c.authors.append((h.email(a), h.person(a)))
227 227 return render('files/files_history_box.html')
228 228
229 229 @LoginRequired(allow_default_user=True)
230 230 @HasRepoPermissionLevelDecorator('read')
231 231 def rawfile(self, repo_name, revision, f_path):
232 232 cs = self.__get_cs(revision)
233 233 file_node = self.__get_filenode(cs, f_path)
234 234
235 235 response.content_disposition = \
236 236 'attachment; filename=%s' % f_path.split(Repository.url_sep())[-1]
237 237
238 238 response.content_type = file_node.mimetype
239 239 return file_node.content
240 240
241 241 @LoginRequired(allow_default_user=True)
242 242 @HasRepoPermissionLevelDecorator('read')
243 243 def raw(self, repo_name, revision, f_path):
244 244 cs = self.__get_cs(revision)
245 245 file_node = self.__get_filenode(cs, f_path)
246 246
247 247 raw_mimetype_mapping = {
248 248 # map original mimetype to a mimetype used for "show as raw"
249 249 # you can also provide a content-disposition to override the
250 250 # default "attachment" disposition.
251 251 # orig_type: (new_type, new_dispo)
252 252
253 253 # show images inline:
254 254 'image/x-icon': ('image/x-icon', 'inline'),
255 255 'image/png': ('image/png', 'inline'),
256 256 'image/gif': ('image/gif', 'inline'),
257 257 'image/jpeg': ('image/jpeg', 'inline'),
258 258 'image/svg+xml': ('image/svg+xml', 'inline'),
259 259 }
260 260
261 261 mimetype = file_node.mimetype
262 262 try:
263 263 mimetype, dispo = raw_mimetype_mapping[mimetype]
264 264 except KeyError:
265 265 # we don't know anything special about this, handle it safely
266 266 if file_node.is_binary:
267 267 # do same as download raw for binary files
268 268 mimetype, dispo = 'application/octet-stream', 'attachment'
269 269 else:
270 270 # do not just use the original mimetype, but force text/plain,
271 271 # otherwise it would serve text/html and that might be unsafe.
272 272 # Note: underlying vcs library fakes text/plain mimetype if the
273 273 # mimetype can not be determined and it thinks it is not
274 274 # binary.This might lead to erroneous text display in some
275 275 # cases, but helps in other cases, like with text files
276 276 # without extension.
277 277 mimetype, dispo = 'text/plain', 'inline'
278 278
279 279 if dispo == 'attachment':
280 280 dispo = 'attachment; filename=%s' % f_path.split(os.sep)[-1]
281 281
282 282 response.content_disposition = dispo
283 283 response.content_type = mimetype
284 284 return file_node.content
285 285
286 286 @LoginRequired()
287 287 @HasRepoPermissionLevelDecorator('write')
288 288 def delete(self, repo_name, revision, f_path):
289 289 repo = c.db_repo
290 290 # check if revision is a branch identifier- basically we cannot
291 291 # create multiple heads via file editing
292 292 _branches = repo.scm_instance.branches
293 293 # check if revision is a branch name or branch hash
294 294 if revision not in _branches and revision not in _branches.values():
295 295 h.flash(_('You can only delete files with revision '
296 296 'being a valid branch'), category='warning')
297 297 raise HTTPFound(location=h.url('files_home',
298 298 repo_name=repo_name, revision='tip',
299 299 f_path=f_path))
300 300
301 301 r_post = request.POST
302 302
303 303 c.cs = self.__get_cs(revision)
304 304 c.file = self.__get_filenode(c.cs, f_path)
305 305
306 306 c.default_message = _('Deleted file %s via Kallithea') % (f_path)
307 307 c.f_path = f_path
308 308 node_path = f_path
309 309 author = request.authuser.full_contact
310 310
311 311 if r_post:
312 312 message = r_post.get('message') or c.default_message
313 313
314 314 try:
315 315 nodes = {
316 316 node_path: {
317 317 'content': ''
318 318 }
319 319 }
320 320 self.scm_model.delete_nodes(
321 321 user=request.authuser.user_id,
322 322 ip_addr=request.ip_addr,
323 323 repo=c.db_repo,
324 324 message=message,
325 325 nodes=nodes,
326 326 parent_cs=c.cs,
327 327 author=author,
328 328 )
329 329
330 330 h.flash(_('Successfully deleted file %s') % f_path,
331 331 category='success')
332 332 except Exception:
333 333 log.error(traceback.format_exc())
334 334 h.flash(_('Error occurred during commit'), category='error')
335 335 raise HTTPFound(location=url('changeset_home',
336 336 repo_name=c.repo_name, revision='tip'))
337 337
338 338 return render('files/files_delete.html')
339 339
340 340 @LoginRequired()
341 341 @HasRepoPermissionLevelDecorator('write')
342 342 def edit(self, repo_name, revision, f_path):
343 343 repo = c.db_repo
344 344 # check if revision is a branch identifier- basically we cannot
345 345 # create multiple heads via file editing
346 346 _branches = repo.scm_instance.branches
347 347 # check if revision is a branch name or branch hash
348 348 if revision not in _branches and revision not in _branches.values():
349 349 h.flash(_('You can only edit files with revision '
350 350 'being a valid branch'), category='warning')
351 351 raise HTTPFound(location=h.url('files_home',
352 352 repo_name=repo_name, revision='tip',
353 353 f_path=f_path))
354 354
355 355 r_post = request.POST
356 356
357 357 c.cs = self.__get_cs(revision)
358 358 c.file = self.__get_filenode(c.cs, f_path)
359 359
360 360 if c.file.is_binary:
361 361 raise HTTPFound(location=url('files_home', repo_name=c.repo_name,
362 362 revision=c.cs.raw_id, f_path=f_path))
363 363 c.default_message = _('Edited file %s via Kallithea') % (f_path)
364 364 c.f_path = f_path
365 365
366 366 if r_post:
367 367 old_content = safe_str(c.file.content)
368 368 sl = old_content.splitlines(1)
369 369 first_line = sl[0] if sl else ''
370 370 # modes: 0 - Unix, 1 - Mac, 2 - DOS
371 371 mode = detect_mode(first_line, 0)
372 372 content = convert_line_endings(r_post.get('content', ''), mode)
373 373
374 374 message = r_post.get('message') or c.default_message
375 375 author = request.authuser.full_contact
376 376
377 377 if content == old_content:
378 378 h.flash(_('No changes'), category='warning')
379 379 raise HTTPFound(location=url('changeset_home', repo_name=c.repo_name,
380 380 revision='tip'))
381 381 try:
382 382 self.scm_model.commit_change(repo=c.db_repo_scm_instance,
383 383 repo_name=repo_name, cs=c.cs,
384 384 user=request.authuser.user_id,
385 385 ip_addr=request.ip_addr,
386 386 author=author, message=message,
387 387 content=content, f_path=f_path)
388 388 h.flash(_('Successfully committed to %s') % f_path,
389 389 category='success')
390 390 except Exception:
391 391 log.error(traceback.format_exc())
392 392 h.flash(_('Error occurred during commit'), category='error')
393 393 raise HTTPFound(location=url('changeset_home',
394 394 repo_name=c.repo_name, revision='tip'))
395 395
396 396 return render('files/files_edit.html')
397 397
398 398 @LoginRequired()
399 399 @HasRepoPermissionLevelDecorator('write')
400 400 def add(self, repo_name, revision, f_path):
401 401
402 402 repo = c.db_repo
403 403 r_post = request.POST
404 404 c.cs = self.__get_cs(revision, silent_empty=True)
405 405 if c.cs is None:
406 406 c.cs = EmptyChangeset(alias=c.db_repo_scm_instance.alias)
407 407 c.default_message = (_('Added file via Kallithea'))
408 408 c.f_path = f_path
409 409
410 410 if r_post:
411 411 unix_mode = 0
412 412 content = convert_line_endings(r_post.get('content', ''), unix_mode)
413 413
414 414 message = r_post.get('message') or c.default_message
415 415 filename = r_post.get('filename')
416 416 location = r_post.get('location', '')
417 417 file_obj = r_post.get('upload_file', None)
418 418
419 419 if file_obj is not None and hasattr(file_obj, 'filename'):
420 420 filename = file_obj.filename
421 421 content = file_obj.file
422 422
423 423 if hasattr(content, 'file'):
424 424 # non posix systems store real file under file attr
425 425 content = content.file
426 426
427 427 if not content:
428 428 h.flash(_('No content'), category='warning')
429 429 raise HTTPFound(location=url('changeset_home', repo_name=c.repo_name,
430 430 revision='tip'))
431 431 if not filename:
432 432 h.flash(_('No filename'), category='warning')
433 433 raise HTTPFound(location=url('changeset_home', repo_name=c.repo_name,
434 434 revision='tip'))
435 435 # strip all crap out of file, just leave the basename
436 436 filename = os.path.basename(filename)
437 437 node_path = posixpath.join(location, filename)
438 438 author = request.authuser.full_contact
439 439
440 440 try:
441 441 nodes = {
442 442 node_path: {
443 443 'content': content
444 444 }
445 445 }
446 446 self.scm_model.create_nodes(
447 447 user=request.authuser.user_id,
448 448 ip_addr=request.ip_addr,
449 449 repo=c.db_repo,
450 450 message=message,
451 451 nodes=nodes,
452 452 parent_cs=c.cs,
453 453 author=author,
454 454 )
455 455
456 456 h.flash(_('Successfully committed to %s') % node_path,
457 457 category='success')
458 458 except NonRelativePathError as e:
459 459 h.flash(_('Location must be relative path and must not '
460 460 'contain .. in path'), category='warning')
461 461 raise HTTPFound(location=url('changeset_home', repo_name=c.repo_name,
462 462 revision='tip'))
463 463 except (NodeError, NodeAlreadyExistsError) as e:
464 464 h.flash(_(e), category='error')
465 465 except Exception:
466 466 log.error(traceback.format_exc())
467 467 h.flash(_('Error occurred during commit'), category='error')
468 468 raise HTTPFound(location=url('changeset_home',
469 469 repo_name=c.repo_name, revision='tip'))
470 470
471 471 return render('files/files_add.html')
472 472
473 473 @LoginRequired(allow_default_user=True)
474 474 @HasRepoPermissionLevelDecorator('read')
475 475 def archivefile(self, repo_name, fname):
476 476 fileformat = None
477 477 revision = None
478 478 ext = None
479 479 subrepos = request.GET.get('subrepos') == 'true'
480 480
481 481 for a_type, ext_data in settings.ARCHIVE_SPECS.items():
482 482 archive_spec = fname.split(ext_data[1])
483 483 if len(archive_spec) == 2 and archive_spec[1] == '':
484 484 fileformat = a_type or ext_data[1]
485 485 revision = archive_spec[0]
486 486 ext = ext_data[1]
487 487
488 488 try:
489 489 dbrepo = RepoModel().get_by_repo_name(repo_name)
490 490 if not dbrepo.enable_downloads:
491 491 return _('Downloads disabled') # TODO: do something else?
492 492
493 493 if c.db_repo_scm_instance.alias == 'hg':
494 494 # patch and reset hooks section of UI config to not run any
495 495 # hooks on fetching archives with subrepos
496 496 for k, v in c.db_repo_scm_instance._repo.ui.configitems('hooks'):
497 497 c.db_repo_scm_instance._repo.ui.setconfig('hooks', k, None)
498 498
499 499 cs = c.db_repo_scm_instance.get_changeset(revision)
500 500 content_type = settings.ARCHIVE_SPECS[fileformat][0]
501 501 except ChangesetDoesNotExistError:
502 502 return _('Unknown revision %s') % revision
503 503 except EmptyRepositoryError:
504 504 return _('Empty repository')
505 505 except (ImproperArchiveTypeError, KeyError):
506 506 return _('Unknown archive type')
507 507
508 508 from kallithea import CONFIG
509 509 rev_name = cs.raw_id[:12]
510 510 archive_name = '%s-%s%s' % (repo_name.replace('/', '_'), rev_name, ext)
511 511
512 512 archive_path = None
513 513 cached_archive_path = None
514 514 archive_cache_dir = CONFIG.get('archive_cache_dir')
515 515 if archive_cache_dir and not subrepos: # TODO: subrepo caching?
516 516 if not os.path.isdir(archive_cache_dir):
517 517 os.makedirs(archive_cache_dir)
518 518 cached_archive_path = os.path.join(archive_cache_dir, archive_name)
519 519 if os.path.isfile(cached_archive_path):
520 520 log.debug('Found cached archive in %s', cached_archive_path)
521 521 archive_path = cached_archive_path
522 522 else:
523 523 log.debug('Archive %s is not yet cached', archive_name)
524 524
525 525 if archive_path is None:
526 526 # generate new archive
527 527 fd, archive_path = tempfile.mkstemp()
528 528 log.debug('Creating new temp archive in %s', archive_path)
529 529 with os.fdopen(fd, 'wb') as stream:
530 530 cs.fill_archive(stream=stream, kind=fileformat, subrepos=subrepos)
531 531 # stream (and thus fd) has been closed by cs.fill_archive
532 532 if cached_archive_path is not None:
533 533 # we generated the archive - move it to cache
534 534 log.debug('Storing new archive in %s', cached_archive_path)
535 535 shutil.move(archive_path, cached_archive_path)
536 536 archive_path = cached_archive_path
537 537
538 538 def get_chunked_archive(archive_path):
539 539 stream = open(archive_path, 'rb')
540 540 while True:
541 541 data = stream.read(16 * 1024)
542 542 if not data:
543 543 break
544 544 yield data
545 545 stream.close()
546 546 if archive_path != cached_archive_path:
547 547 log.debug('Destroying temp archive %s', archive_path)
548 548 os.remove(archive_path)
549 549
550 550 action_logger(user=request.authuser,
551 551 action='user_downloaded_archive:%s' % (archive_name),
552 552 repo=repo_name, ipaddr=request.ip_addr, commit=True)
553 553
554 554 response.content_disposition = str('attachment; filename=%s' % (archive_name))
555 555 response.content_type = str(content_type)
556 556 return get_chunked_archive(archive_path)
557 557
558 558 @LoginRequired(allow_default_user=True)
559 559 @HasRepoPermissionLevelDecorator('read')
560 560 def diff(self, repo_name, f_path):
561 561 ignore_whitespace = request.GET.get('ignorews') == '1'
562 562 line_context = safe_int(request.GET.get('context'), 3)
563 563 diff2 = request.GET.get('diff2', '')
564 564 diff1 = request.GET.get('diff1', '') or diff2
565 565 c.action = request.GET.get('diff')
566 566 c.no_changes = diff1 == diff2
567 567 c.f_path = f_path
568 568 c.big_diff = False
569 569 fulldiff = request.GET.get('fulldiff')
570 570 c.anchor_url = anchor_url
571 571 c.ignorews_url = _ignorews_url
572 572 c.context_url = _context_url
573 573 c.changes = OrderedDict()
574 574 c.changes[diff2] = []
575 575
576 576 # special case if we want a show rev only, it's impl here
577 577 # to reduce JS and callbacks
578 578
579 579 if request.GET.get('show_rev'):
580 580 if str2bool(request.GET.get('annotate', 'False')):
581 581 _url = url('files_annotate_home', repo_name=c.repo_name,
582 582 revision=diff1, f_path=c.f_path)
583 583 else:
584 584 _url = url('files_home', repo_name=c.repo_name,
585 585 revision=diff1, f_path=c.f_path)
586 586
587 587 raise HTTPFound(location=_url)
588 588 try:
589 589 if diff1 not in ['', None, 'None', '0' * 12, '0' * 40]:
590 590 c.changeset_1 = c.db_repo_scm_instance.get_changeset(diff1)
591 591 try:
592 592 node1 = c.changeset_1.get_node(f_path)
593 593 if node1.is_dir():
594 594 raise NodeError('%s path is a %s not a file'
595 595 % (node1, type(node1)))
596 596 except NodeDoesNotExistError:
597 597 c.changeset_1 = EmptyChangeset(cs=diff1,
598 598 revision=c.changeset_1.revision,
599 599 repo=c.db_repo_scm_instance)
600 600 node1 = FileNode(f_path, '', changeset=c.changeset_1)
601 601 else:
602 602 c.changeset_1 = EmptyChangeset(repo=c.db_repo_scm_instance)
603 603 node1 = FileNode(f_path, '', changeset=c.changeset_1)
604 604
605 605 if diff2 not in ['', None, 'None', '0' * 12, '0' * 40]:
606 606 c.changeset_2 = c.db_repo_scm_instance.get_changeset(diff2)
607 607 try:
608 608 node2 = c.changeset_2.get_node(f_path)
609 609 if node2.is_dir():
610 610 raise NodeError('%s path is a %s not a file'
611 611 % (node2, type(node2)))
612 612 except NodeDoesNotExistError:
613 613 c.changeset_2 = EmptyChangeset(cs=diff2,
614 614 revision=c.changeset_2.revision,
615 615 repo=c.db_repo_scm_instance)
616 616 node2 = FileNode(f_path, '', changeset=c.changeset_2)
617 617 else:
618 618 c.changeset_2 = EmptyChangeset(repo=c.db_repo_scm_instance)
619 619 node2 = FileNode(f_path, '', changeset=c.changeset_2)
620 620 except (RepositoryError, NodeError):
621 621 log.error(traceback.format_exc())
622 622 raise HTTPFound(location=url('files_home', repo_name=c.repo_name,
623 623 f_path=f_path))
624 624
625 625 if c.action == 'download':
626 626 raw_diff = diffs.get_gitdiff(node1, node2,
627 627 ignore_whitespace=ignore_whitespace,
628 628 context=line_context)
629 629 diff_name = '%s_vs_%s.diff' % (diff1, diff2)
630 630 response.content_type = 'text/plain'
631 631 response.content_disposition = (
632 632 'attachment; filename=%s' % diff_name
633 633 )
634 634 return raw_diff
635 635
636 636 elif c.action == 'raw':
637 637 raw_diff = diffs.get_gitdiff(node1, node2,
638 638 ignore_whitespace=ignore_whitespace,
639 639 context=line_context)
640 640 response.content_type = 'text/plain'
641 641 return raw_diff
642 642
643 643 else:
644 644 fid = h.FID(diff2, node2.path)
645 645 line_context_lcl = get_line_ctx(fid, request.GET)
646 646 ign_whitespace_lcl = get_ignore_ws(fid, request.GET)
647 647
648 648 diff_limit = None if fulldiff else self.cut_off_limit
649 649 c.a_rev, c.cs_rev, a_path, diff, st, op = diffs.wrapped_diff(filenode_old=node1,
650 650 filenode_new=node2,
651 651 diff_limit=diff_limit,
652 652 ignore_whitespace=ign_whitespace_lcl,
653 653 line_context=line_context_lcl,
654 654 enable_comments=False)
655 655 c.file_diff_data = [(fid, fid, op, a_path, node2.path, diff, st)]
656 656
657 657 return render('files/file_diff.html')
658 658
659 659 @LoginRequired(allow_default_user=True)
660 660 @HasRepoPermissionLevelDecorator('read')
661 661 def diff_2way(self, repo_name, f_path):
662 662 diff1 = request.GET.get('diff1', '')
663 663 diff2 = request.GET.get('diff2', '')
664 664 try:
665 665 if diff1 not in ['', None, 'None', '0' * 12, '0' * 40]:
666 666 c.changeset_1 = c.db_repo_scm_instance.get_changeset(diff1)
667 667 try:
668 668 node1 = c.changeset_1.get_node(f_path)
669 669 if node1.is_dir():
670 670 raise NodeError('%s path is a %s not a file'
671 671 % (node1, type(node1)))
672 672 except NodeDoesNotExistError:
673 673 c.changeset_1 = EmptyChangeset(cs=diff1,
674 674 revision=c.changeset_1.revision,
675 675 repo=c.db_repo_scm_instance)
676 676 node1 = FileNode(f_path, '', changeset=c.changeset_1)
677 677 else:
678 678 c.changeset_1 = EmptyChangeset(repo=c.db_repo_scm_instance)
679 679 node1 = FileNode(f_path, '', changeset=c.changeset_1)
680 680
681 681 if diff2 not in ['', None, 'None', '0' * 12, '0' * 40]:
682 682 c.changeset_2 = c.db_repo_scm_instance.get_changeset(diff2)
683 683 try:
684 684 node2 = c.changeset_2.get_node(f_path)
685 685 if node2.is_dir():
686 686 raise NodeError('%s path is a %s not a file'
687 687 % (node2, type(node2)))
688 688 except NodeDoesNotExistError:
689 689 c.changeset_2 = EmptyChangeset(cs=diff2,
690 690 revision=c.changeset_2.revision,
691 691 repo=c.db_repo_scm_instance)
692 692 node2 = FileNode(f_path, '', changeset=c.changeset_2)
693 693 else:
694 694 c.changeset_2 = EmptyChangeset(repo=c.db_repo_scm_instance)
695 695 node2 = FileNode(f_path, '', changeset=c.changeset_2)
696 696 except ChangesetDoesNotExistError as e:
697 697 msg = _('Such revision does not exist for this repository')
698 698 h.flash(msg, category='error')
699 699 raise HTTPNotFound()
700 700 c.node1 = node1
701 701 c.node2 = node2
702 702 c.cs1 = c.changeset_1
703 703 c.cs2 = c.changeset_2
704 704
705 705 return render('files/diff_2way.html')
706 706
707 707 def _get_node_history(self, cs, f_path, changesets=None):
708 708 """
709 709 get changesets history for given node
710 710
711 711 :param cs: changeset to calculate history
712 712 :param f_path: path for node to calculate history for
713 713 :param changesets: if passed don't calculate history and take
714 714 changesets defined in this list
715 715 """
716 716 # calculate history based on tip
717 717 tip_cs = c.db_repo_scm_instance.get_changeset()
718 718 if changesets is None:
719 719 try:
720 720 changesets = tip_cs.get_file_history(f_path)
721 721 except (NodeDoesNotExistError, ChangesetError):
722 722 # this node is not present at tip !
723 723 changesets = cs.get_file_history(f_path)
724 724 hist_l = []
725 725
726 726 changesets_group = ([], _("Changesets"))
727 727 branches_group = ([], _("Branches"))
728 728 tags_group = ([], _("Tags"))
729 729 for chs in changesets:
730 730 # TODO: loop over chs.branches ... but that will not give all the bogus None branches for Git ...
731 731 _branch = chs.branch
732 732 n_desc = '%s (%s)' % (h.show_id(chs), _branch)
733 733 changesets_group[0].append((chs.raw_id, n_desc,))
734 734 hist_l.append(changesets_group)
735 735
736 736 for name, chs in c.db_repo_scm_instance.branches.items():
737 737 branches_group[0].append((chs, name),)
738 738 hist_l.append(branches_group)
739 739
740 740 for name, chs in c.db_repo_scm_instance.tags.items():
741 741 tags_group[0].append((chs, name),)
742 742 hist_l.append(tags_group)
743 743
744 744 return hist_l, changesets
745 745
746 746 @LoginRequired(allow_default_user=True)
747 747 @HasRepoPermissionLevelDecorator('read')
748 748 @jsonify
749 749 def nodelist(self, repo_name, revision, f_path):
750 750 if request.environ.get('HTTP_X_PARTIAL_XHR'):
751 751 cs = self.__get_cs(revision)
752 752 _d, _f = ScmModel().get_nodes(repo_name, cs.raw_id, f_path,
753 753 flat=False)
754 754 return {'nodes': _d + _f}
@@ -1,833 +1,833 b''
1 1 # -*- coding: utf-8 -*-
2 2 # This program is free software: you can redistribute it and/or modify
3 3 # it under the terms of the GNU General Public License as published by
4 4 # the Free Software Foundation, either version 3 of the License, or
5 5 # (at your option) any later version.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 """
15 15 kallithea.lib.auth
16 16 ~~~~~~~~~~~~~~~~~~
17 17
18 18 authentication and permission libraries
19 19
20 20 This file was forked by the Kallithea project in July 2014.
21 21 Original author and date, and relevant copyright and licensing information is below:
22 22 :created_on: Apr 4, 2010
23 23 :author: marcink
24 24 :copyright: (c) 2013 RhodeCode GmbH, and others.
25 25 :license: GPLv3, see LICENSE.md for more details.
26 26 """
27 27 import hashlib
28 28 import itertools
29 29 import logging
30 30 import os
31 31 import string
32 32
33 33 import bcrypt
34 34 import ipaddr
35 35 from decorator import decorator
36 36 from sqlalchemy.orm import joinedload
37 37 from sqlalchemy.orm.exc import ObjectDeletedError
38 38 from tg import request
39 39 from tg.i18n import ugettext as _
40 40 from webob.exc import HTTPForbidden, HTTPFound
41 41
42 42 from kallithea.config.routing import url
43 43 from kallithea.lib.caching_query import FromCache
44 44 from kallithea.lib.utils import conditional_cache, get_repo_group_slug, get_repo_slug, get_user_group_slug
45 45 from kallithea.lib.utils2 import ascii_bytes, ascii_str, safe_bytes
46 46 from kallithea.lib.vcs.utils.lazy import LazyProperty
47 from kallithea.model.db import (
48 Permission, RepoGroup, Repository, User, UserApiKeys, UserGroup, UserGroupMember, UserGroupRepoGroupToPerm, UserGroupRepoToPerm, UserGroupToPerm, UserGroupUserGroupToPerm, UserIpMap, UserToPerm)
47 from kallithea.model.db import (Permission, RepoGroup, Repository, User, UserApiKeys, UserGroup, UserGroupMember, UserGroupRepoGroupToPerm, UserGroupRepoToPerm,
48 UserGroupToPerm, UserGroupUserGroupToPerm, UserIpMap, UserToPerm)
49 49 from kallithea.model.meta import Session
50 50 from kallithea.model.user import UserModel
51 51
52 52
53 53 log = logging.getLogger(__name__)
54 54
55 55
56 56 class PasswordGenerator(object):
57 57 """
58 58 This is a simple class for generating password from different sets of
59 59 characters
60 60 usage::
61 61
62 62 passwd_gen = PasswordGenerator()
63 63 #print 8-letter password containing only big and small letters
64 64 of alphabet
65 65 passwd_gen.gen_password(8, passwd_gen.ALPHABETS_BIG_SMALL)
66 66 """
67 67 ALPHABETS_NUM = r'''1234567890'''
68 68 ALPHABETS_SMALL = r'''qwertyuiopasdfghjklzxcvbnm'''
69 69 ALPHABETS_BIG = r'''QWERTYUIOPASDFGHJKLZXCVBNM'''
70 70 ALPHABETS_SPECIAL = r'''`-=[]\;',./~!@#$%^&*()_+{}|:"<>?'''
71 71 ALPHABETS_FULL = ALPHABETS_BIG + ALPHABETS_SMALL \
72 72 + ALPHABETS_NUM + ALPHABETS_SPECIAL
73 73 ALPHABETS_ALPHANUM = ALPHABETS_BIG + ALPHABETS_SMALL + ALPHABETS_NUM
74 74 ALPHABETS_BIG_SMALL = ALPHABETS_BIG + ALPHABETS_SMALL
75 75 ALPHABETS_ALPHANUM_BIG = ALPHABETS_BIG + ALPHABETS_NUM
76 76 ALPHABETS_ALPHANUM_SMALL = ALPHABETS_SMALL + ALPHABETS_NUM
77 77
78 78 def gen_password(self, length, alphabet=ALPHABETS_FULL):
79 79 assert len(alphabet) <= 256, alphabet
80 80 l = []
81 81 while len(l) < length:
82 82 i = ord(os.urandom(1))
83 83 if i < len(alphabet):
84 84 l.append(alphabet[i])
85 85 return ''.join(l)
86 86
87 87
88 88 def get_crypt_password(password):
89 89 """
90 90 Cryptographic function used for bcrypt password hashing.
91 91
92 92 :param password: password to hash
93 93 """
94 94 return ascii_str(bcrypt.hashpw(safe_bytes(password), bcrypt.gensalt(10)))
95 95
96 96
97 97 def check_password(password, hashed):
98 98 """
99 99 Checks password match the hashed value using bcrypt.
100 100 Remains backwards compatible and accept plain sha256 hashes which used to
101 101 be used on Windows.
102 102
103 103 :param password: password
104 104 :param hashed: password in hashed form
105 105 """
106 106 # sha256 hashes will always be 64 hex chars
107 107 # bcrypt hashes will always contain $ (and be shorter)
108 108 if len(hashed) == 64 and all(x in string.hexdigits for x in hashed):
109 109 return hashlib.sha256(password).hexdigest() == hashed
110 110 try:
111 111 return bcrypt.checkpw(safe_bytes(password), ascii_bytes(hashed))
112 112 except ValueError as e:
113 113 # bcrypt will throw ValueError 'Invalid hashed_password salt' on all password errors
114 114 log.error('error from bcrypt checking password: %s', e)
115 115 return False
116 116 log.error('check_password failed - no method found for hash length %s', len(hashed))
117 117 return False
118 118
119 119
120 120 def _cached_perms_data(user_id, user_is_admin):
121 121 RK = 'repositories'
122 122 GK = 'repositories_groups'
123 123 UK = 'user_groups'
124 124 GLOBAL = 'global'
125 125 PERM_WEIGHTS = Permission.PERM_WEIGHTS
126 126 permissions = {RK: {}, GK: {}, UK: {}, GLOBAL: set()}
127 127
128 128 def bump_permission(kind, key, new_perm):
129 129 """Add a new permission for kind and key.
130 130 Assuming the permissions are comparable, set the new permission if it
131 131 has higher weight, else drop it and keep the old permission.
132 132 """
133 133 cur_perm = permissions[kind][key]
134 134 new_perm_val = PERM_WEIGHTS[new_perm]
135 135 cur_perm_val = PERM_WEIGHTS[cur_perm]
136 136 if new_perm_val > cur_perm_val:
137 137 permissions[kind][key] = new_perm
138 138
139 139 #======================================================================
140 140 # fetch default permissions
141 141 #======================================================================
142 142 default_user = User.get_by_username('default', cache=True)
143 143 default_user_id = default_user.user_id
144 144
145 145 default_repo_perms = Permission.get_default_perms(default_user_id)
146 146 default_repo_groups_perms = Permission.get_default_group_perms(default_user_id)
147 147 default_user_group_perms = Permission.get_default_user_group_perms(default_user_id)
148 148
149 149 if user_is_admin:
150 150 #==================================================================
151 151 # admin users have all rights;
152 152 # based on default permissions, just set everything to admin
153 153 #==================================================================
154 154 permissions[GLOBAL].add('hg.admin')
155 155 permissions[GLOBAL].add('hg.create.write_on_repogroup.true')
156 156
157 157 # repositories
158 158 for perm in default_repo_perms:
159 159 r_k = perm.UserRepoToPerm.repository.repo_name
160 160 p = 'repository.admin'
161 161 permissions[RK][r_k] = p
162 162
163 163 # repository groups
164 164 for perm in default_repo_groups_perms:
165 165 rg_k = perm.UserRepoGroupToPerm.group.group_name
166 166 p = 'group.admin'
167 167 permissions[GK][rg_k] = p
168 168
169 169 # user groups
170 170 for perm in default_user_group_perms:
171 171 u_k = perm.UserUserGroupToPerm.user_group.users_group_name
172 172 p = 'usergroup.admin'
173 173 permissions[UK][u_k] = p
174 174 return permissions
175 175
176 176 #==================================================================
177 177 # SET DEFAULTS GLOBAL, REPOS, REPOSITORY GROUPS
178 178 #==================================================================
179 179
180 180 # default global permissions taken from the default user
181 181 default_global_perms = UserToPerm.query() \
182 182 .filter(UserToPerm.user_id == default_user_id) \
183 183 .options(joinedload(UserToPerm.permission))
184 184
185 185 for perm in default_global_perms:
186 186 permissions[GLOBAL].add(perm.permission.permission_name)
187 187
188 188 # defaults for repositories, taken from default user
189 189 for perm in default_repo_perms:
190 190 r_k = perm.UserRepoToPerm.repository.repo_name
191 191 if perm.Repository.owner_id == user_id:
192 192 p = 'repository.admin'
193 193 elif perm.Repository.private:
194 194 p = 'repository.none'
195 195 else:
196 196 p = perm.Permission.permission_name
197 197 permissions[RK][r_k] = p
198 198
199 199 # defaults for repository groups taken from default user permission
200 200 # on given group
201 201 for perm in default_repo_groups_perms:
202 202 rg_k = perm.UserRepoGroupToPerm.group.group_name
203 203 p = perm.Permission.permission_name
204 204 permissions[GK][rg_k] = p
205 205
206 206 # defaults for user groups taken from default user permission
207 207 # on given user group
208 208 for perm in default_user_group_perms:
209 209 u_k = perm.UserUserGroupToPerm.user_group.users_group_name
210 210 p = perm.Permission.permission_name
211 211 permissions[UK][u_k] = p
212 212
213 213 #======================================================================
214 214 # !! Augment GLOBALS with user permissions if any found !!
215 215 #======================================================================
216 216
217 217 # USER GROUPS comes first
218 218 # user group global permissions
219 219 user_perms_from_users_groups = Session().query(UserGroupToPerm) \
220 220 .options(joinedload(UserGroupToPerm.permission)) \
221 221 .join((UserGroupMember, UserGroupToPerm.users_group_id ==
222 222 UserGroupMember.users_group_id)) \
223 223 .filter(UserGroupMember.user_id == user_id) \
224 224 .join((UserGroup, UserGroupMember.users_group_id ==
225 225 UserGroup.users_group_id)) \
226 226 .filter(UserGroup.users_group_active == True) \
227 227 .order_by(UserGroupToPerm.users_group_id) \
228 228 .all()
229 229 # need to group here by groups since user can be in more than
230 230 # one group
231 231 _grouped = [[x, list(y)] for x, y in
232 232 itertools.groupby(user_perms_from_users_groups,
233 233 lambda x:x.users_group)]
234 234 for gr, perms in _grouped:
235 235 for perm in perms:
236 236 permissions[GLOBAL].add(perm.permission.permission_name)
237 237
238 238 # user specific global permissions
239 239 user_perms = Session().query(UserToPerm) \
240 240 .options(joinedload(UserToPerm.permission)) \
241 241 .filter(UserToPerm.user_id == user_id).all()
242 242
243 243 for perm in user_perms:
244 244 permissions[GLOBAL].add(perm.permission.permission_name)
245 245
246 246 # for each kind of global permissions, only keep the one with heighest weight
247 247 kind_max_perm = {}
248 248 for perm in sorted(permissions[GLOBAL], key=lambda n: PERM_WEIGHTS[n]):
249 249 kind = perm.rsplit('.', 1)[0]
250 250 kind_max_perm[kind] = perm
251 251 permissions[GLOBAL] = set(kind_max_perm.values())
252 252 ## END GLOBAL PERMISSIONS
253 253
254 254 #======================================================================
255 255 # !! PERMISSIONS FOR REPOSITORIES !!
256 256 #======================================================================
257 257 #======================================================================
258 258 # check if user is part of user groups for this repository and
259 259 # fill in his permission from it.
260 260 #======================================================================
261 261
262 262 # user group for repositories permissions
263 263 user_repo_perms_from_users_groups = \
264 264 Session().query(UserGroupRepoToPerm, Permission, Repository,) \
265 265 .join((Repository, UserGroupRepoToPerm.repository_id ==
266 266 Repository.repo_id)) \
267 267 .join((Permission, UserGroupRepoToPerm.permission_id ==
268 268 Permission.permission_id)) \
269 269 .join((UserGroup, UserGroupRepoToPerm.users_group_id ==
270 270 UserGroup.users_group_id)) \
271 271 .filter(UserGroup.users_group_active == True) \
272 272 .join((UserGroupMember, UserGroupRepoToPerm.users_group_id ==
273 273 UserGroupMember.users_group_id)) \
274 274 .filter(UserGroupMember.user_id == user_id) \
275 275 .all()
276 276
277 277 for perm in user_repo_perms_from_users_groups:
278 278 bump_permission(RK,
279 279 perm.UserGroupRepoToPerm.repository.repo_name,
280 280 perm.Permission.permission_name)
281 281
282 282 # user permissions for repositories
283 283 user_repo_perms = Permission.get_default_perms(user_id)
284 284 for perm in user_repo_perms:
285 285 bump_permission(RK,
286 286 perm.UserRepoToPerm.repository.repo_name,
287 287 perm.Permission.permission_name)
288 288
289 289 #======================================================================
290 290 # !! PERMISSIONS FOR REPOSITORY GROUPS !!
291 291 #======================================================================
292 292 #======================================================================
293 293 # check if user is part of user groups for this repository groups and
294 294 # fill in his permission from it.
295 295 #======================================================================
296 296 # user group for repo groups permissions
297 297 user_repo_group_perms_from_users_groups = \
298 298 Session().query(UserGroupRepoGroupToPerm, Permission, RepoGroup) \
299 299 .join((RepoGroup, UserGroupRepoGroupToPerm.group_id == RepoGroup.group_id)) \
300 300 .join((Permission, UserGroupRepoGroupToPerm.permission_id
301 301 == Permission.permission_id)) \
302 302 .join((UserGroup, UserGroupRepoGroupToPerm.users_group_id ==
303 303 UserGroup.users_group_id)) \
304 304 .filter(UserGroup.users_group_active == True) \
305 305 .join((UserGroupMember, UserGroupRepoGroupToPerm.users_group_id
306 306 == UserGroupMember.users_group_id)) \
307 307 .filter(UserGroupMember.user_id == user_id) \
308 308 .all()
309 309
310 310 for perm in user_repo_group_perms_from_users_groups:
311 311 bump_permission(GK,
312 312 perm.UserGroupRepoGroupToPerm.group.group_name,
313 313 perm.Permission.permission_name)
314 314
315 315 # user explicit permissions for repository groups
316 316 user_repo_groups_perms = Permission.get_default_group_perms(user_id)
317 317 for perm in user_repo_groups_perms:
318 318 bump_permission(GK,
319 319 perm.UserRepoGroupToPerm.group.group_name,
320 320 perm.Permission.permission_name)
321 321
322 322 #======================================================================
323 323 # !! PERMISSIONS FOR USER GROUPS !!
324 324 #======================================================================
325 325 # user group for user group permissions
326 326 user_group_user_groups_perms = \
327 327 Session().query(UserGroupUserGroupToPerm, Permission, UserGroup) \
328 328 .join((UserGroup, UserGroupUserGroupToPerm.target_user_group_id
329 329 == UserGroup.users_group_id)) \
330 330 .join((Permission, UserGroupUserGroupToPerm.permission_id
331 331 == Permission.permission_id)) \
332 332 .join((UserGroupMember, UserGroupUserGroupToPerm.user_group_id
333 333 == UserGroupMember.users_group_id)) \
334 334 .filter(UserGroupMember.user_id == user_id) \
335 335 .join((UserGroup, UserGroupMember.users_group_id ==
336 336 UserGroup.users_group_id), aliased=True, from_joinpoint=True) \
337 337 .filter(UserGroup.users_group_active == True) \
338 338 .all()
339 339
340 340 for perm in user_group_user_groups_perms:
341 341 bump_permission(UK,
342 342 perm.UserGroupUserGroupToPerm.target_user_group.users_group_name,
343 343 perm.Permission.permission_name)
344 344
345 345 # user explicit permission for user groups
346 346 user_user_groups_perms = Permission.get_default_user_group_perms(user_id)
347 347 for perm in user_user_groups_perms:
348 348 bump_permission(UK,
349 349 perm.UserUserGroupToPerm.user_group.users_group_name,
350 350 perm.Permission.permission_name)
351 351
352 352 return permissions
353 353
354 354
355 355 class AuthUser(object):
356 356 """
357 357 Represents a Kallithea user, including various authentication and
358 358 authorization information. Typically used to store the current user,
359 359 but is also used as a generic user information data structure in
360 360 parts of the code, e.g. user management.
361 361
362 362 Constructed from a database `User` object, a user ID or cookie dict,
363 363 it looks up the user (if needed) and copies all attributes to itself,
364 364 adding various non-persistent data. If lookup fails but anonymous
365 365 access to Kallithea is enabled, the default user is loaded instead.
366 366
367 367 `AuthUser` does not by itself authenticate users. It's up to other parts of
368 368 the code to check e.g. if a supplied password is correct, and if so, trust
369 369 the AuthUser object as an authenticated user.
370 370
371 371 However, `AuthUser` does refuse to load a user that is not `active`.
372 372
373 373 Note that Kallithea distinguishes between the default user (an actual
374 374 user in the database with username "default") and "no user" (no actual
375 375 User object, AuthUser filled with blank values and username "None").
376 376
377 377 If the default user is active, that will always be used instead of
378 378 "no user". On the other hand, if the default user is disabled (and
379 379 there is no login information), we instead get "no user"; this should
380 380 only happen on the login page (as all other requests are redirected).
381 381
382 382 `is_default_user` specifically checks if the AuthUser is the user named
383 383 "default". Use `is_anonymous` to check for both "default" and "no user".
384 384 """
385 385
386 386 @classmethod
387 387 def make(cls, dbuser=None, is_external_auth=False, ip_addr=None):
388 388 """Create an AuthUser to be authenticated ... or return None if user for some reason can't be authenticated.
389 389 Checks that a non-None dbuser is provided, is active, and that the IP address is ok.
390 390 """
391 391 assert ip_addr is not None
392 392 if dbuser is None:
393 393 log.info('No db user for authentication')
394 394 return None
395 395 if not dbuser.active:
396 396 log.info('Db user %s not active', dbuser.username)
397 397 return None
398 398 allowed_ips = AuthUser.get_allowed_ips(dbuser.user_id, cache=True)
399 399 if not check_ip_access(source_ip=ip_addr, allowed_ips=allowed_ips):
400 400 log.info('Access for %s from %s forbidden - not in %s', dbuser.username, ip_addr, allowed_ips)
401 401 return None
402 402 return cls(dbuser=dbuser, is_external_auth=is_external_auth)
403 403
404 404 def __init__(self, user_id=None, dbuser=None, is_external_auth=False):
405 405 self.is_external_auth = is_external_auth # container auth - don't show logout option
406 406
407 407 # These attributes will be overridden below if the requested user is
408 408 # found or anonymous access (using the default user) is enabled.
409 409 self.user_id = None
410 410 self.username = None
411 411 self.api_key = None
412 412 self.name = ''
413 413 self.lastname = ''
414 414 self.email = ''
415 415 self.admin = False
416 416
417 417 # Look up database user, if necessary.
418 418 if user_id is not None:
419 419 assert dbuser is None
420 420 log.debug('Auth User lookup by USER ID %s', user_id)
421 421 dbuser = UserModel().get(user_id)
422 422 assert dbuser is not None
423 423 else:
424 424 assert dbuser is not None
425 425 log.debug('Auth User lookup by database user %s', dbuser)
426 426
427 427 log.debug('filling %s data', dbuser)
428 428 self.is_anonymous = dbuser.is_default_user
429 429 if dbuser.is_default_user and not dbuser.active:
430 430 self.username = 'None'
431 431 self.is_default_user = False
432 432 else:
433 433 # copy non-confidential database fields from a `db.User` to this `AuthUser`.
434 434 for k, v in dbuser.get_dict().items():
435 435 assert k not in ['api_keys', 'permissions']
436 436 setattr(self, k, v)
437 437 self.is_default_user = dbuser.is_default_user
438 438 log.debug('Auth User is now %s', self)
439 439
440 440 @LazyProperty
441 441 def permissions(self):
442 442 return self.__get_perms(user=self, cache=False)
443 443
444 444 def has_repository_permission_level(self, repo_name, level, purpose=None):
445 445 required_perms = {
446 446 'read': ['repository.read', 'repository.write', 'repository.admin'],
447 447 'write': ['repository.write', 'repository.admin'],
448 448 'admin': ['repository.admin'],
449 449 }[level]
450 450 actual_perm = self.permissions['repositories'].get(repo_name)
451 451 ok = actual_perm in required_perms
452 452 log.debug('Checking if user %r can %r repo %r (%s): %s (has %r)',
453 453 self.username, level, repo_name, purpose, ok, actual_perm)
454 454 return ok
455 455
456 456 def has_repository_group_permission_level(self, repo_group_name, level, purpose=None):
457 457 required_perms = {
458 458 'read': ['group.read', 'group.write', 'group.admin'],
459 459 'write': ['group.write', 'group.admin'],
460 460 'admin': ['group.admin'],
461 461 }[level]
462 462 actual_perm = self.permissions['repositories_groups'].get(repo_group_name)
463 463 ok = actual_perm in required_perms
464 464 log.debug('Checking if user %r can %r repo group %r (%s): %s (has %r)',
465 465 self.username, level, repo_group_name, purpose, ok, actual_perm)
466 466 return ok
467 467
468 468 def has_user_group_permission_level(self, user_group_name, level, purpose=None):
469 469 required_perms = {
470 470 'read': ['usergroup.read', 'usergroup.write', 'usergroup.admin'],
471 471 'write': ['usergroup.write', 'usergroup.admin'],
472 472 'admin': ['usergroup.admin'],
473 473 }[level]
474 474 actual_perm = self.permissions['user_groups'].get(user_group_name)
475 475 ok = actual_perm in required_perms
476 476 log.debug('Checking if user %r can %r user group %r (%s): %s (has %r)',
477 477 self.username, level, user_group_name, purpose, ok, actual_perm)
478 478 return ok
479 479
480 480 @property
481 481 def api_keys(self):
482 482 return self._get_api_keys()
483 483
484 484 def __get_perms(self, user, cache=False):
485 485 """
486 486 Fills user permission attribute with permissions taken from database
487 487 works for permissions given for repositories, and for permissions that
488 488 are granted to groups
489 489
490 490 :param user: `AuthUser` instance
491 491 """
492 492 user_id = user.user_id
493 493 user_is_admin = user.is_admin
494 494
495 495 log.debug('Getting PERMISSION tree')
496 496 compute = conditional_cache('short_term', 'cache_desc',
497 497 condition=cache, func=_cached_perms_data)
498 498 return compute(user_id, user_is_admin)
499 499
500 500 def _get_api_keys(self):
501 501 api_keys = [self.api_key]
502 502 for api_key in UserApiKeys.query() \
503 503 .filter_by(user_id=self.user_id, is_expired=False):
504 504 api_keys.append(api_key.api_key)
505 505
506 506 return api_keys
507 507
508 508 @property
509 509 def is_admin(self):
510 510 return self.admin
511 511
512 512 @property
513 513 def repositories_admin(self):
514 514 """
515 515 Returns list of repositories you're an admin of
516 516 """
517 517 return [x[0] for x in self.permissions['repositories'].items()
518 518 if x[1] == 'repository.admin']
519 519
520 520 @property
521 521 def repository_groups_admin(self):
522 522 """
523 523 Returns list of repository groups you're an admin of
524 524 """
525 525 return [x[0] for x in self.permissions['repositories_groups'].items()
526 526 if x[1] == 'group.admin']
527 527
528 528 @property
529 529 def user_groups_admin(self):
530 530 """
531 531 Returns list of user groups you're an admin of
532 532 """
533 533 return [x[0] for x in self.permissions['user_groups'].items()
534 534 if x[1] == 'usergroup.admin']
535 535
536 536 def __repr__(self):
537 537 return "<%s %s: %r>" % (self.__class__.__name__, self.user_id, self.username)
538 538
539 539 def to_cookie(self):
540 540 """ Serializes this login session to a cookie `dict`. """
541 541 return {
542 542 'user_id': self.user_id,
543 543 'is_external_auth': self.is_external_auth,
544 544 }
545 545
546 546 @staticmethod
547 547 def from_cookie(cookie, ip_addr):
548 548 """
549 549 Deserializes an `AuthUser` from a cookie `dict` ... or return None.
550 550 """
551 551 return AuthUser.make(
552 552 dbuser=UserModel().get(cookie.get('user_id')),
553 553 is_external_auth=cookie.get('is_external_auth', False),
554 554 ip_addr=ip_addr,
555 555 )
556 556
557 557 @classmethod
558 558 def get_allowed_ips(cls, user_id, cache=False):
559 559 _set = set()
560 560
561 561 default_ips = UserIpMap.query().filter(UserIpMap.user_id ==
562 562 User.get_default_user(cache=True).user_id)
563 563 if cache:
564 564 default_ips = default_ips.options(FromCache("sql_cache_short",
565 565 "get_user_ips_default"))
566 566 for ip in default_ips:
567 567 try:
568 568 _set.add(ip.ip_addr)
569 569 except ObjectDeletedError:
570 570 # since we use heavy caching sometimes it happens that we get
571 571 # deleted objects here, we just skip them
572 572 pass
573 573
574 574 user_ips = UserIpMap.query().filter(UserIpMap.user_id == user_id)
575 575 if cache:
576 576 user_ips = user_ips.options(FromCache("sql_cache_short",
577 577 "get_user_ips_%s" % user_id))
578 578 for ip in user_ips:
579 579 try:
580 580 _set.add(ip.ip_addr)
581 581 except ObjectDeletedError:
582 582 # since we use heavy caching sometimes it happens that we get
583 583 # deleted objects here, we just skip them
584 584 pass
585 585 return _set or set(['0.0.0.0/0', '::/0'])
586 586
587 587
588 588 #==============================================================================
589 589 # CHECK DECORATORS
590 590 #==============================================================================
591 591
592 592 def _redirect_to_login(message=None):
593 593 """Return an exception that must be raised. It will redirect to the login
594 594 page which will redirect back to the current URL after authentication.
595 595 The optional message will be shown in a flash message."""
596 596 from kallithea.lib import helpers as h
597 597 if message:
598 598 h.flash(message, category='warning')
599 599 p = request.path_qs
600 600 log.debug('Redirecting to login page, origin: %s', p)
601 601 return HTTPFound(location=url('login_home', came_from=p))
602 602
603 603
604 604 # Use as decorator
605 605 class LoginRequired(object):
606 606 """Client must be logged in as a valid User, or we'll redirect to the login
607 607 page.
608 608
609 609 If the "default" user is enabled and allow_default_user is true, that is
610 610 considered valid too.
611 611
612 612 Also checks that IP address is allowed.
613 613 """
614 614
615 615 def __init__(self, allow_default_user=False):
616 616 self.allow_default_user = allow_default_user
617 617
618 618 def __call__(self, func):
619 619 return decorator(self.__wrapper, func)
620 620
621 621 def __wrapper(self, func, *fargs, **fkwargs):
622 622 controller = fargs[0]
623 623 user = request.authuser
624 624 loc = "%s:%s" % (controller.__class__.__name__, func.__name__)
625 625 log.debug('Checking access for user %s @ %s', user, loc)
626 626
627 627 # regular user authentication
628 628 if user.is_default_user:
629 629 if self.allow_default_user:
630 630 log.info('default user @ %s', loc)
631 631 return func(*fargs, **fkwargs)
632 632 log.info('default user is not accepted here @ %s', loc)
633 633 elif user.is_anonymous: # default user is disabled and no proper authentication
634 634 log.warning('user is anonymous and NOT authenticated with regular auth @ %s', loc)
635 635 else: # regular authentication
636 636 log.info('user %s authenticated with regular auth @ %s', user, loc)
637 637 return func(*fargs, **fkwargs)
638 638 raise _redirect_to_login()
639 639
640 640
641 641 # Use as decorator
642 642 class NotAnonymous(object):
643 643 """Ensures that client is not logged in as the "default" user, and
644 644 redirects to the login page otherwise. Must be used together with
645 645 LoginRequired."""
646 646
647 647 def __call__(self, func):
648 648 return decorator(self.__wrapper, func)
649 649
650 650 def __wrapper(self, func, *fargs, **fkwargs):
651 651 cls = fargs[0]
652 652 user = request.authuser
653 653
654 654 log.debug('Checking that user %s is not anonymous @%s', user.username, cls)
655 655
656 656 if user.is_default_user:
657 657 raise _redirect_to_login(_('You need to be a registered user to '
658 658 'perform this action'))
659 659 else:
660 660 return func(*fargs, **fkwargs)
661 661
662 662
663 663 class _PermsDecorator(object):
664 664 """Base class for controller decorators with multiple permissions"""
665 665
666 666 def __init__(self, *required_perms):
667 667 self.required_perms = required_perms # usually very short - a list is thus fine
668 668
669 669 def __call__(self, func):
670 670 return decorator(self.__wrapper, func)
671 671
672 672 def __wrapper(self, func, *fargs, **fkwargs):
673 673 cls = fargs[0]
674 674 user = request.authuser
675 675 log.debug('checking %s permissions %s for %s %s',
676 676 self.__class__.__name__, self.required_perms, cls, user)
677 677
678 678 if self.check_permissions(user):
679 679 log.debug('Permission granted for %s %s', cls, user)
680 680 return func(*fargs, **fkwargs)
681 681
682 682 else:
683 683 log.info('Permission denied for %s %s', cls, user)
684 684 if user.is_default_user:
685 685 raise _redirect_to_login(_('You need to be signed in to view this page'))
686 686 else:
687 687 raise HTTPForbidden()
688 688
689 689 def check_permissions(self, user):
690 690 raise NotImplementedError()
691 691
692 692
693 693 class HasPermissionAnyDecorator(_PermsDecorator):
694 694 """
695 695 Checks the user has any of the given global permissions.
696 696 """
697 697
698 698 def check_permissions(self, user):
699 699 global_permissions = user.permissions['global'] # usually very short
700 700 return any(p in global_permissions for p in self.required_perms)
701 701
702 702
703 703 class _PermDecorator(_PermsDecorator):
704 704 """Base class for controller decorators with a single permission"""
705 705
706 706 def __init__(self, required_perm):
707 707 _PermsDecorator.__init__(self, [required_perm])
708 708 self.required_perm = required_perm
709 709
710 710
711 711 class HasRepoPermissionLevelDecorator(_PermDecorator):
712 712 """
713 713 Checks the user has at least the specified permission level for the requested repository.
714 714 """
715 715
716 716 def check_permissions(self, user):
717 717 repo_name = get_repo_slug(request)
718 718 return user.has_repository_permission_level(repo_name, self.required_perm)
719 719
720 720
721 721 class HasRepoGroupPermissionLevelDecorator(_PermDecorator):
722 722 """
723 723 Checks the user has any of given permissions for the requested repository group.
724 724 """
725 725
726 726 def check_permissions(self, user):
727 727 repo_group_name = get_repo_group_slug(request)
728 728 return user.has_repository_group_permission_level(repo_group_name, self.required_perm)
729 729
730 730
731 731 class HasUserGroupPermissionLevelDecorator(_PermDecorator):
732 732 """
733 733 Checks for access permission for any of given predicates for specific
734 734 user group. In order to fulfill the request any of predicates must be meet
735 735 """
736 736
737 737 def check_permissions(self, user):
738 738 user_group_name = get_user_group_slug(request)
739 739 return user.has_user_group_permission_level(user_group_name, self.required_perm)
740 740
741 741
742 742 #==============================================================================
743 743 # CHECK FUNCTIONS
744 744 #==============================================================================
745 745
746 746 class _PermsFunction(object):
747 747 """Base function for other check functions with multiple permissions"""
748 748
749 749 def __init__(self, *required_perms):
750 750 self.required_perms = required_perms # usually very short - a list is thus fine
751 751
752 752 def __bool__(self):
753 753 """ Defend against accidentally forgetting to call the object
754 754 and instead evaluating it directly in a boolean context,
755 755 which could have security implications.
756 756 """
757 757 raise AssertionError(self.__class__.__name__ + ' is not a bool and must be called!')
758 758
759 759 def __call__(self, *a, **b):
760 760 raise NotImplementedError()
761 761
762 762
763 763 class HasPermissionAny(_PermsFunction):
764 764
765 765 def __call__(self, purpose=None):
766 766 global_permissions = request.authuser.permissions['global'] # usually very short
767 767 ok = any(p in global_permissions for p in self.required_perms)
768 768
769 769 log.debug('Check %s for global %s (%s): %s',
770 770 request.authuser.username, self.required_perms, purpose, ok)
771 771 return ok
772 772
773 773
774 774 class _PermFunction(_PermsFunction):
775 775 """Base function for other check functions with a single permission"""
776 776
777 777 def __init__(self, required_perm):
778 778 _PermsFunction.__init__(self, [required_perm])
779 779 self.required_perm = required_perm
780 780
781 781
782 782 class HasRepoPermissionLevel(_PermFunction):
783 783
784 784 def __call__(self, repo_name, purpose=None):
785 785 return request.authuser.has_repository_permission_level(repo_name, self.required_perm, purpose)
786 786
787 787
788 788 class HasRepoGroupPermissionLevel(_PermFunction):
789 789
790 790 def __call__(self, group_name, purpose=None):
791 791 return request.authuser.has_repository_group_permission_level(group_name, self.required_perm, purpose)
792 792
793 793
794 794 class HasUserGroupPermissionLevel(_PermFunction):
795 795
796 796 def __call__(self, user_group_name, purpose=None):
797 797 return request.authuser.has_user_group_permission_level(user_group_name, self.required_perm, purpose)
798 798
799 799
800 800 #==============================================================================
801 801 # SPECIAL VERSION TO HANDLE MIDDLEWARE AUTH
802 802 #==============================================================================
803 803
804 804 class HasPermissionAnyMiddleware(object):
805 805 def __init__(self, *perms):
806 806 self.required_perms = set(perms)
807 807
808 808 def __call__(self, authuser, repo_name, purpose=None):
809 809 try:
810 810 ok = authuser.permissions['repositories'][repo_name] in self.required_perms
811 811 except KeyError:
812 812 ok = False
813 813
814 814 log.debug('Middleware check %s for %s for repo %s (%s): %s', authuser.username, self.required_perms, repo_name, purpose, ok)
815 815 return ok
816 816
817 817
818 818 def check_ip_access(source_ip, allowed_ips=None):
819 819 """
820 820 Checks if source_ip is a subnet of any of allowed_ips.
821 821
822 822 :param source_ip:
823 823 :param allowed_ips: list of allowed ips together with mask
824 824 """
825 825 source_ip = source_ip.split('%', 1)[0]
826 826 log.debug('checking if ip:%s is subnet of %s', source_ip, allowed_ips)
827 827 if isinstance(allowed_ips, (tuple, list, set)):
828 828 for ip in allowed_ips:
829 829 if ipaddr.IPAddress(source_ip) in ipaddr.IPNetwork(ip):
830 830 log.debug('IP %s is network %s',
831 831 ipaddr.IPAddress(source_ip), ipaddr.IPNetwork(ip))
832 832 return True
833 833 return False
@@ -1,1055 +1,1055 b''
1 1 # -*- coding: utf-8 -*-
2 2 """
3 3 vcs.backends.base
4 4 ~~~~~~~~~~~~~~~~~
5 5
6 6 Base for all available scm backends
7 7
8 8 :created_on: Apr 8, 2010
9 9 :copyright: (c) 2010-2011 by Marcin Kuzminski, Lukasz Balcerzak.
10 10 """
11 11
12 12 import datetime
13 13 import itertools
14 14
15 15 from kallithea.lib.vcs.conf import settings
16 from kallithea.lib.vcs.exceptions import (
17 ChangesetError, EmptyRepositoryError, NodeAlreadyAddedError, NodeAlreadyChangedError, NodeAlreadyExistsError, NodeAlreadyRemovedError, NodeDoesNotExistError, NodeNotChangedError, RepositoryError)
16 from kallithea.lib.vcs.exceptions import (ChangesetError, EmptyRepositoryError, NodeAlreadyAddedError, NodeAlreadyChangedError, NodeAlreadyExistsError,
17 NodeAlreadyRemovedError, NodeDoesNotExistError, NodeNotChangedError, RepositoryError)
18 18 from kallithea.lib.vcs.utils import author_email, author_name
19 19 from kallithea.lib.vcs.utils.helpers import get_dict_for_attrs
20 20 from kallithea.lib.vcs.utils.lazy import LazyProperty
21 21
22 22
23 23 class BaseRepository(object):
24 24 """
25 25 Base Repository for final backends
26 26
27 27 **Attributes**
28 28
29 29 ``DEFAULT_BRANCH_NAME``
30 30 name of default branch (i.e. "trunk" for svn, "master" for git etc.
31 31
32 32 ``scm``
33 33 alias of scm, i.e. *git* or *hg*
34 34
35 35 ``repo``
36 36 object from external api
37 37
38 38 ``revisions``
39 39 list of all available revisions' ids, in ascending order
40 40
41 41 ``changesets``
42 42 storage dict caching returned changesets
43 43
44 44 ``path``
45 45 absolute path to the repository
46 46
47 47 ``branches``
48 48 branches as list of changesets
49 49
50 50 ``tags``
51 51 tags as list of changesets
52 52 """
53 53 scm = None
54 54 DEFAULT_BRANCH_NAME = None
55 55 EMPTY_CHANGESET = '0' * 40
56 56
57 57 def __init__(self, repo_path, create=False, **kwargs):
58 58 """
59 59 Initializes repository. Raises RepositoryError if repository could
60 60 not be find at the given ``repo_path`` or directory at ``repo_path``
61 61 exists and ``create`` is set to True.
62 62
63 63 :param repo_path: local path of the repository
64 64 :param create=False: if set to True, would try to create repository.
65 65 :param src_url=None: if set, should be proper url from which repository
66 66 would be cloned; requires ``create`` parameter to be set to True -
67 67 raises RepositoryError if src_url is set and create evaluates to
68 68 False
69 69 """
70 70 raise NotImplementedError
71 71
72 72 def __str__(self):
73 73 return '<%s at %s>' % (self.__class__.__name__, self.path)
74 74
75 75 def __repr__(self):
76 76 return self.__str__()
77 77
78 78 def __len__(self):
79 79 return self.count()
80 80
81 81 def __eq__(self, other):
82 82 same_instance = isinstance(other, self.__class__)
83 83 return same_instance and getattr(other, 'path', None) == self.path
84 84
85 85 def __ne__(self, other):
86 86 return not self.__eq__(other)
87 87
88 88 @LazyProperty
89 89 def alias(self):
90 90 for k, v in settings.BACKENDS.items():
91 91 if v.split('.')[-1] == str(self.__class__.__name__):
92 92 return k
93 93
94 94 @LazyProperty
95 95 def name(self):
96 96 """
97 97 Return repository name (without group name)
98 98 """
99 99 raise NotImplementedError
100 100
101 101 @LazyProperty
102 102 def owner(self):
103 103 raise NotImplementedError
104 104
105 105 @LazyProperty
106 106 def description(self):
107 107 raise NotImplementedError
108 108
109 109 @LazyProperty
110 110 def size(self):
111 111 """
112 112 Returns combined size in bytes for all repository files
113 113 """
114 114
115 115 size = 0
116 116 try:
117 117 tip = self.get_changeset()
118 118 for topnode, dirs, files in tip.walk('/'):
119 119 for f in files:
120 120 size += tip.get_file_size(f.path)
121 121
122 122 except RepositoryError as e:
123 123 pass
124 124 return size
125 125
126 126 def is_valid(self):
127 127 """
128 128 Validates repository.
129 129 """
130 130 raise NotImplementedError
131 131
132 132 def is_empty(self):
133 133 return self._empty
134 134
135 135 #==========================================================================
136 136 # CHANGESETS
137 137 #==========================================================================
138 138
139 139 def get_changeset(self, revision=None):
140 140 """
141 141 Returns instance of ``Changeset`` class. If ``revision`` is None, most
142 142 recent changeset is returned.
143 143
144 144 :raises ``EmptyRepositoryError``: if there are no revisions
145 145 """
146 146 raise NotImplementedError
147 147
148 148 def __iter__(self):
149 149 """
150 150 Allows Repository objects to be iterated.
151 151
152 152 *Requires* implementation of ``__getitem__`` method.
153 153 """
154 154 for revision in self.revisions:
155 155 yield self.get_changeset(revision)
156 156
157 157 def get_changesets(self, start=None, end=None, start_date=None,
158 158 end_date=None, branch_name=None, reverse=False, max_revisions=None):
159 159 """
160 160 Returns iterator of ``BaseChangeset`` objects from start to end,
161 161 both inclusive.
162 162
163 163 :param start: None or str
164 164 :param end: None or str
165 165 :param start_date:
166 166 :param end_date:
167 167 :param branch_name:
168 168 :param reversed:
169 169 """
170 170 raise NotImplementedError
171 171
172 172 def __getitem__(self, key):
173 173 if isinstance(key, slice):
174 174 return (self.get_changeset(rev) for rev in self.revisions[key])
175 175 return self.get_changeset(key)
176 176
177 177 def count(self):
178 178 return len(self.revisions)
179 179
180 180 def tag(self, name, user, revision=None, message=None, date=None, **opts):
181 181 """
182 182 Creates and returns a tag for the given ``revision``.
183 183
184 184 :param name: name for new tag
185 185 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
186 186 :param revision: changeset id for which new tag would be created
187 187 :param message: message of the tag's commit
188 188 :param date: date of tag's commit
189 189
190 190 :raises TagAlreadyExistError: if tag with same name already exists
191 191 """
192 192 raise NotImplementedError
193 193
194 194 def remove_tag(self, name, user, message=None, date=None):
195 195 """
196 196 Removes tag with the given ``name``.
197 197
198 198 :param name: name of the tag to be removed
199 199 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
200 200 :param message: message of the tag's removal commit
201 201 :param date: date of tag's removal commit
202 202
203 203 :raises TagDoesNotExistError: if tag with given name does not exists
204 204 """
205 205 raise NotImplementedError
206 206
207 207 def get_diff(self, rev1, rev2, path=None, ignore_whitespace=False,
208 208 context=3):
209 209 """
210 210 Returns (git like) *diff*, as plain text. Shows changes introduced by
211 211 ``rev2`` since ``rev1``.
212 212
213 213 :param rev1: Entry point from which diff is shown. Can be
214 214 ``self.EMPTY_CHANGESET`` - in this case, patch showing all
215 215 the changes since empty state of the repository until ``rev2``
216 216 :param rev2: Until which revision changes should be shown.
217 217 :param ignore_whitespace: If set to ``True``, would not show whitespace
218 218 changes. Defaults to ``False``.
219 219 :param context: How many lines before/after changed lines should be
220 220 shown. Defaults to ``3``.
221 221 """
222 222 raise NotImplementedError
223 223
224 224 # ========== #
225 225 # COMMIT API #
226 226 # ========== #
227 227
228 228 @LazyProperty
229 229 def in_memory_changeset(self):
230 230 """
231 231 Returns ``InMemoryChangeset`` object for this repository.
232 232 """
233 233 raise NotImplementedError
234 234
235 235 def add(self, filenode, **kwargs):
236 236 """
237 237 Commit api function that will add given ``FileNode`` into this
238 238 repository.
239 239
240 240 :raises ``NodeAlreadyExistsError``: if there is a file with same path
241 241 already in repository
242 242 :raises ``NodeAlreadyAddedError``: if given node is already marked as
243 243 *added*
244 244 """
245 245 raise NotImplementedError
246 246
247 247 def remove(self, filenode, **kwargs):
248 248 """
249 249 Commit api function that will remove given ``FileNode`` into this
250 250 repository.
251 251
252 252 :raises ``EmptyRepositoryError``: if there are no changesets yet
253 253 :raises ``NodeDoesNotExistError``: if there is no file with given path
254 254 """
255 255 raise NotImplementedError
256 256
257 257 def commit(self, message, **kwargs):
258 258 """
259 259 Persists current changes made on this repository and returns newly
260 260 created changeset.
261 261 """
262 262 raise NotImplementedError
263 263
264 264 def get_state(self):
265 265 """
266 266 Returns dictionary with ``added``, ``changed`` and ``removed`` lists
267 267 containing ``FileNode`` objects.
268 268 """
269 269 raise NotImplementedError
270 270
271 271 def get_config_value(self, section, name, config_file=None):
272 272 """
273 273 Returns configuration value for a given [``section``] and ``name``.
274 274
275 275 :param section: Section we want to retrieve value from
276 276 :param name: Name of configuration we want to retrieve
277 277 :param config_file: A path to file which should be used to retrieve
278 278 configuration from (might also be a list of file paths)
279 279 """
280 280 raise NotImplementedError
281 281
282 282 def get_user_name(self, config_file=None):
283 283 """
284 284 Returns user's name from global configuration file.
285 285
286 286 :param config_file: A path to file which should be used to retrieve
287 287 configuration from (might also be a list of file paths)
288 288 """
289 289 raise NotImplementedError
290 290
291 291 def get_user_email(self, config_file=None):
292 292 """
293 293 Returns user's email from global configuration file.
294 294
295 295 :param config_file: A path to file which should be used to retrieve
296 296 configuration from (might also be a list of file paths)
297 297 """
298 298 raise NotImplementedError
299 299
300 300 # =========== #
301 301 # WORKDIR API #
302 302 # =========== #
303 303
304 304 @LazyProperty
305 305 def workdir(self):
306 306 """
307 307 Returns ``Workdir`` instance for this repository.
308 308 """
309 309 raise NotImplementedError
310 310
311 311
312 312 class BaseChangeset(object):
313 313 """
314 314 Each backend should implement it's changeset representation.
315 315
316 316 **Attributes**
317 317
318 318 ``repository``
319 319 repository object within which changeset exists
320 320
321 321 ``raw_id``
322 322 raw changeset representation (i.e. full 40 length sha for git
323 323 backend)
324 324
325 325 ``short_id``
326 326 shortened (if apply) version of ``raw_id``; it would be simple
327 327 shortcut for ``raw_id[:12]`` for git/mercurial backends or same
328 328 as ``raw_id`` for subversion
329 329
330 330 ``revision``
331 331 revision number as integer
332 332
333 333 ``files``
334 334 list of ``FileNode`` (``Node`` with NodeKind.FILE) objects
335 335
336 336 ``dirs``
337 337 list of ``DirNode`` (``Node`` with NodeKind.DIR) objects
338 338
339 339 ``nodes``
340 340 combined list of ``Node`` objects
341 341
342 342 ``author``
343 343 author of the changeset, as str
344 344
345 345 ``message``
346 346 message of the changeset, as str
347 347
348 348 ``parents``
349 349 list of parent changesets
350 350
351 351 ``last``
352 352 ``True`` if this is last changeset in repository, ``False``
353 353 otherwise; trying to access this attribute while there is no
354 354 changesets would raise ``EmptyRepositoryError``
355 355 """
356 356 def __str__(self):
357 357 return '<%s at %s:%s>' % (self.__class__.__name__, self.revision,
358 358 self.short_id)
359 359
360 360 def __repr__(self):
361 361 return self.__str__()
362 362
363 363 def __eq__(self, other):
364 364 if type(self) is not type(other):
365 365 return False
366 366 return self.raw_id == other.raw_id
367 367
368 368 def __json__(self, with_file_list=False):
369 369 if with_file_list:
370 370 return dict(
371 371 short_id=self.short_id,
372 372 raw_id=self.raw_id,
373 373 revision=self.revision,
374 374 message=self.message,
375 375 date=self.date,
376 376 author=self.author,
377 377 added=[el.path for el in self.added],
378 378 changed=[el.path for el in self.changed],
379 379 removed=[el.path for el in self.removed],
380 380 )
381 381 else:
382 382 return dict(
383 383 short_id=self.short_id,
384 384 raw_id=self.raw_id,
385 385 revision=self.revision,
386 386 message=self.message,
387 387 date=self.date,
388 388 author=self.author,
389 389 )
390 390
391 391 @LazyProperty
392 392 def last(self):
393 393 if self.repository is None:
394 394 raise ChangesetError("Cannot check if it's most recent revision")
395 395 return self.raw_id == self.repository.revisions[-1]
396 396
397 397 @LazyProperty
398 398 def parents(self):
399 399 """
400 400 Returns list of parents changesets.
401 401 """
402 402 raise NotImplementedError
403 403
404 404 @LazyProperty
405 405 def children(self):
406 406 """
407 407 Returns list of children changesets.
408 408 """
409 409 raise NotImplementedError
410 410
411 411 @LazyProperty
412 412 def raw_id(self):
413 413 """
414 414 Returns raw string identifying this changeset.
415 415 """
416 416 raise NotImplementedError
417 417
418 418 @LazyProperty
419 419 def short_id(self):
420 420 """
421 421 Returns shortened version of ``raw_id`` attribute, as string,
422 422 identifying this changeset, useful for web representation.
423 423 """
424 424 raise NotImplementedError
425 425
426 426 @LazyProperty
427 427 def revision(self):
428 428 """
429 429 Returns integer identifying this changeset.
430 430
431 431 """
432 432 raise NotImplementedError
433 433
434 434 @LazyProperty
435 435 def committer(self):
436 436 """
437 437 Returns Committer for given commit
438 438 """
439 439
440 440 raise NotImplementedError
441 441
442 442 @LazyProperty
443 443 def committer_name(self):
444 444 """
445 445 Returns Author name for given commit
446 446 """
447 447
448 448 return author_name(self.committer)
449 449
450 450 @LazyProperty
451 451 def committer_email(self):
452 452 """
453 453 Returns Author email address for given commit
454 454 """
455 455
456 456 return author_email(self.committer)
457 457
458 458 @LazyProperty
459 459 def author(self):
460 460 """
461 461 Returns Author for given commit
462 462 """
463 463
464 464 raise NotImplementedError
465 465
466 466 @LazyProperty
467 467 def author_name(self):
468 468 """
469 469 Returns Author name for given commit
470 470 """
471 471
472 472 return author_name(self.author)
473 473
474 474 @LazyProperty
475 475 def author_email(self):
476 476 """
477 477 Returns Author email address for given commit
478 478 """
479 479
480 480 return author_email(self.author)
481 481
482 482 def get_file_mode(self, path):
483 483 """
484 484 Returns stat mode of the file at the given ``path``.
485 485 """
486 486 raise NotImplementedError
487 487
488 488 def get_file_content(self, path):
489 489 """
490 490 Returns content of the file at the given ``path``.
491 491 """
492 492 raise NotImplementedError
493 493
494 494 def get_file_size(self, path):
495 495 """
496 496 Returns size of the file at the given ``path``.
497 497 """
498 498 raise NotImplementedError
499 499
500 500 def get_file_changeset(self, path):
501 501 """
502 502 Returns last commit of the file at the given ``path``.
503 503 """
504 504 raise NotImplementedError
505 505
506 506 def get_file_history(self, path):
507 507 """
508 508 Returns history of file as reversed list of ``Changeset`` objects for
509 509 which file at given ``path`` has been modified.
510 510 """
511 511 raise NotImplementedError
512 512
513 513 def get_nodes(self, path):
514 514 """
515 515 Returns combined ``DirNode`` and ``FileNode`` objects list representing
516 516 state of changeset at the given ``path``.
517 517
518 518 :raises ``ChangesetError``: if node at the given ``path`` is not
519 519 instance of ``DirNode``
520 520 """
521 521 raise NotImplementedError
522 522
523 523 def get_node(self, path):
524 524 """
525 525 Returns ``Node`` object from the given ``path``.
526 526
527 527 :raises ``NodeDoesNotExistError``: if there is no node at the given
528 528 ``path``
529 529 """
530 530 raise NotImplementedError
531 531
532 532 def fill_archive(self, stream=None, kind='tgz', prefix=None):
533 533 """
534 534 Fills up given stream.
535 535
536 536 :param stream: file like object.
537 537 :param kind: one of following: ``zip``, ``tar``, ``tgz``
538 538 or ``tbz2``. Default: ``tgz``.
539 539 :param prefix: name of root directory in archive.
540 540 Default is repository name and changeset's raw_id joined with dash.
541 541
542 542 repo-tip.<kind>
543 543 """
544 544
545 545 raise NotImplementedError
546 546
547 547 def get_chunked_archive(self, **kwargs):
548 548 """
549 549 Returns iterable archive. Tiny wrapper around ``fill_archive`` method.
550 550
551 551 :param chunk_size: extra parameter which controls size of returned
552 552 chunks. Default:8k.
553 553 """
554 554
555 555 chunk_size = kwargs.pop('chunk_size', 8192)
556 556 stream = kwargs.get('stream')
557 557 self.fill_archive(**kwargs)
558 558 while True:
559 559 data = stream.read(chunk_size)
560 560 if not data:
561 561 break
562 562 yield data
563 563
564 564 @LazyProperty
565 565 def root(self):
566 566 """
567 567 Returns ``RootNode`` object for this changeset.
568 568 """
569 569 return self.get_node('')
570 570
571 571 def next(self, branch=None):
572 572 """
573 573 Returns next changeset from current, if branch is gives it will return
574 574 next changeset belonging to this branch
575 575
576 576 :param branch: show changesets within the given named branch
577 577 """
578 578 raise NotImplementedError
579 579
580 580 def prev(self, branch=None):
581 581 """
582 582 Returns previous changeset from current, if branch is gives it will
583 583 return previous changeset belonging to this branch
584 584
585 585 :param branch: show changesets within the given named branch
586 586 """
587 587 raise NotImplementedError
588 588
589 589 @LazyProperty
590 590 def added(self):
591 591 """
592 592 Returns list of added ``FileNode`` objects.
593 593 """
594 594 raise NotImplementedError
595 595
596 596 @LazyProperty
597 597 def changed(self):
598 598 """
599 599 Returns list of modified ``FileNode`` objects.
600 600 """
601 601 raise NotImplementedError
602 602
603 603 @LazyProperty
604 604 def removed(self):
605 605 """
606 606 Returns list of removed ``FileNode`` objects.
607 607 """
608 608 raise NotImplementedError
609 609
610 610 @LazyProperty
611 611 def size(self):
612 612 """
613 613 Returns total number of bytes from contents of all filenodes.
614 614 """
615 615 return sum((node.size for node in self.get_filenodes_generator()))
616 616
617 617 def walk(self, topurl=''):
618 618 """
619 619 Similar to os.walk method. Instead of filesystem it walks through
620 620 changeset starting at given ``topurl``. Returns generator of tuples
621 621 (topnode, dirnodes, filenodes).
622 622 """
623 623 topnode = self.get_node(topurl)
624 624 yield (topnode, topnode.dirs, topnode.files)
625 625 for dirnode in topnode.dirs:
626 626 for tup in self.walk(dirnode.path):
627 627 yield tup
628 628
629 629 def get_filenodes_generator(self):
630 630 """
631 631 Returns generator that yields *all* file nodes.
632 632 """
633 633 for topnode, dirs, files in self.walk():
634 634 for node in files:
635 635 yield node
636 636
637 637 def as_dict(self):
638 638 """
639 639 Returns dictionary with changeset's attributes and their values.
640 640 """
641 641 data = get_dict_for_attrs(self, ['raw_id', 'short_id',
642 642 'revision', 'date', 'message'])
643 643 data['author'] = {'name': self.author_name, 'email': self.author_email}
644 644 data['added'] = [node.path for node in self.added]
645 645 data['changed'] = [node.path for node in self.changed]
646 646 data['removed'] = [node.path for node in self.removed]
647 647 return data
648 648
649 649 @LazyProperty
650 650 def closesbranch(self):
651 651 return False
652 652
653 653 @LazyProperty
654 654 def obsolete(self):
655 655 return False
656 656
657 657 @LazyProperty
658 658 def bumped(self):
659 659 return False
660 660
661 661 @LazyProperty
662 662 def divergent(self):
663 663 return False
664 664
665 665 @LazyProperty
666 666 def extinct(self):
667 667 return False
668 668
669 669 @LazyProperty
670 670 def unstable(self):
671 671 return False
672 672
673 673 @LazyProperty
674 674 def phase(self):
675 675 return ''
676 676
677 677
678 678 class BaseWorkdir(object):
679 679 """
680 680 Working directory representation of single repository.
681 681
682 682 :attribute: repository: repository object of working directory
683 683 """
684 684
685 685 def __init__(self, repository):
686 686 self.repository = repository
687 687
688 688 def get_branch(self):
689 689 """
690 690 Returns name of current branch.
691 691 """
692 692 raise NotImplementedError
693 693
694 694 def get_changeset(self):
695 695 """
696 696 Returns current changeset.
697 697 """
698 698 raise NotImplementedError
699 699
700 700 def get_added(self):
701 701 """
702 702 Returns list of ``FileNode`` objects marked as *new* in working
703 703 directory.
704 704 """
705 705 raise NotImplementedError
706 706
707 707 def get_changed(self):
708 708 """
709 709 Returns list of ``FileNode`` objects *changed* in working directory.
710 710 """
711 711 raise NotImplementedError
712 712
713 713 def get_removed(self):
714 714 """
715 715 Returns list of ``RemovedFileNode`` objects marked as *removed* in
716 716 working directory.
717 717 """
718 718 raise NotImplementedError
719 719
720 720 def get_untracked(self):
721 721 """
722 722 Returns list of ``FileNode`` objects which are present within working
723 723 directory however are not tracked by repository.
724 724 """
725 725 raise NotImplementedError
726 726
727 727 def get_status(self):
728 728 """
729 729 Returns dict with ``added``, ``changed``, ``removed`` and ``untracked``
730 730 lists.
731 731 """
732 732 raise NotImplementedError
733 733
734 734 def commit(self, message, **kwargs):
735 735 """
736 736 Commits local (from working directory) changes and returns newly
737 737 created
738 738 ``Changeset``. Updates repository's ``revisions`` list.
739 739
740 740 :raises ``CommitError``: if any error occurs while committing
741 741 """
742 742 raise NotImplementedError
743 743
744 744 def update(self, revision=None):
745 745 """
746 746 Fetches content of the given revision and populates it within working
747 747 directory.
748 748 """
749 749 raise NotImplementedError
750 750
751 751 def checkout_branch(self, branch=None):
752 752 """
753 753 Checks out ``branch`` or the backend's default branch.
754 754
755 755 Raises ``BranchDoesNotExistError`` if the branch does not exist.
756 756 """
757 757 raise NotImplementedError
758 758
759 759
760 760 class BaseInMemoryChangeset(object):
761 761 """
762 762 Represents differences between repository's state (most recent head) and
763 763 changes made *in place*.
764 764
765 765 **Attributes**
766 766
767 767 ``repository``
768 768 repository object for this in-memory-changeset
769 769
770 770 ``added``
771 771 list of ``FileNode`` objects marked as *added*
772 772
773 773 ``changed``
774 774 list of ``FileNode`` objects marked as *changed*
775 775
776 776 ``removed``
777 777 list of ``FileNode`` or ``RemovedFileNode`` objects marked to be
778 778 *removed*
779 779
780 780 ``parents``
781 781 list of ``Changeset`` representing parents of in-memory changeset.
782 782 Should always be 2-element sequence.
783 783
784 784 """
785 785
786 786 def __init__(self, repository):
787 787 self.repository = repository
788 788 self.added = []
789 789 self.changed = []
790 790 self.removed = []
791 791 self.parents = []
792 792
793 793 def add(self, *filenodes):
794 794 """
795 795 Marks given ``FileNode`` objects as *to be committed*.
796 796
797 797 :raises ``NodeAlreadyExistsError``: if node with same path exists at
798 798 latest changeset
799 799 :raises ``NodeAlreadyAddedError``: if node with same path is already
800 800 marked as *added*
801 801 """
802 802 # Check if not already marked as *added* first
803 803 for node in filenodes:
804 804 if node.path in (n.path for n in self.added):
805 805 raise NodeAlreadyAddedError("Such FileNode %s is already "
806 806 "marked for addition" % node.path)
807 807 for node in filenodes:
808 808 self.added.append(node)
809 809
810 810 def change(self, *filenodes):
811 811 """
812 812 Marks given ``FileNode`` objects to be *changed* in next commit.
813 813
814 814 :raises ``EmptyRepositoryError``: if there are no changesets yet
815 815 :raises ``NodeAlreadyExistsError``: if node with same path is already
816 816 marked to be *changed*
817 817 :raises ``NodeAlreadyRemovedError``: if node with same path is already
818 818 marked to be *removed*
819 819 :raises ``NodeDoesNotExistError``: if node doesn't exist in latest
820 820 changeset
821 821 :raises ``NodeNotChangedError``: if node hasn't really be changed
822 822 """
823 823 for node in filenodes:
824 824 if node.path in (n.path for n in self.removed):
825 825 raise NodeAlreadyRemovedError("Node at %s is already marked "
826 826 "as removed" % node.path)
827 827 try:
828 828 self.repository.get_changeset()
829 829 except EmptyRepositoryError:
830 830 raise EmptyRepositoryError("Nothing to change - try to *add* new "
831 831 "nodes rather than changing them")
832 832 for node in filenodes:
833 833 if node.path in (n.path for n in self.changed):
834 834 raise NodeAlreadyChangedError("Node at '%s' is already "
835 835 "marked as changed" % node.path)
836 836 self.changed.append(node)
837 837
838 838 def remove(self, *filenodes):
839 839 """
840 840 Marks given ``FileNode`` (or ``RemovedFileNode``) objects to be
841 841 *removed* in next commit.
842 842
843 843 :raises ``NodeAlreadyRemovedError``: if node has been already marked to
844 844 be *removed*
845 845 :raises ``NodeAlreadyChangedError``: if node has been already marked to
846 846 be *changed*
847 847 """
848 848 for node in filenodes:
849 849 if node.path in (n.path for n in self.removed):
850 850 raise NodeAlreadyRemovedError("Node is already marked to "
851 851 "for removal at %s" % node.path)
852 852 if node.path in (n.path for n in self.changed):
853 853 raise NodeAlreadyChangedError("Node is already marked to "
854 854 "be changed at %s" % node.path)
855 855 # We only mark node as *removed* - real removal is done by
856 856 # commit method
857 857 self.removed.append(node)
858 858
859 859 def reset(self):
860 860 """
861 861 Resets this instance to initial state (cleans ``added``, ``changed``
862 862 and ``removed`` lists).
863 863 """
864 864 self.added = []
865 865 self.changed = []
866 866 self.removed = []
867 867 self.parents = []
868 868
869 869 def get_ipaths(self):
870 870 """
871 871 Returns generator of paths from nodes marked as added, changed or
872 872 removed.
873 873 """
874 874 for node in itertools.chain(self.added, self.changed, self.removed):
875 875 yield node.path
876 876
877 877 def get_paths(self):
878 878 """
879 879 Returns list of paths from nodes marked as added, changed or removed.
880 880 """
881 881 return list(self.get_ipaths())
882 882
883 883 def check_integrity(self, parents=None):
884 884 """
885 885 Checks in-memory changeset's integrity. Also, sets parents if not
886 886 already set.
887 887
888 888 :raises CommitError: if any error occurs (i.e.
889 889 ``NodeDoesNotExistError``).
890 890 """
891 891 if not self.parents:
892 892 parents = parents or []
893 893 if len(parents) == 0:
894 894 try:
895 895 parents = [self.repository.get_changeset(), None]
896 896 except EmptyRepositoryError:
897 897 parents = [None, None]
898 898 elif len(parents) == 1:
899 899 parents += [None]
900 900 self.parents = parents
901 901
902 902 # Local parents, only if not None
903 903 parents = [p for p in self.parents if p]
904 904
905 905 # Check nodes marked as added
906 906 for p in parents:
907 907 for node in self.added:
908 908 try:
909 909 p.get_node(node.path)
910 910 except NodeDoesNotExistError:
911 911 pass
912 912 else:
913 913 raise NodeAlreadyExistsError("Node at %s already exists "
914 914 "at %s" % (node.path, p))
915 915
916 916 # Check nodes marked as changed
917 917 missing = set(node.path for node in self.changed)
918 918 not_changed = set(node.path for node in self.changed)
919 919 if self.changed and not parents:
920 920 raise NodeDoesNotExistError(self.changed[0].path)
921 921 for p in parents:
922 922 for node in self.changed:
923 923 try:
924 924 old = p.get_node(node.path)
925 925 missing.remove(node.path)
926 926 # if content actually changed, remove node from unchanged
927 927 if old.content != node.content:
928 928 not_changed.remove(node.path)
929 929 except NodeDoesNotExistError:
930 930 pass
931 931 if self.changed and missing:
932 932 raise NodeDoesNotExistError("Node at %s is missing "
933 933 "(parents: %s)" % (node.path, parents))
934 934
935 935 if self.changed and not_changed:
936 936 raise NodeNotChangedError("Node at %s wasn't actually changed "
937 937 "since parents' changesets: %s" % (not_changed.pop(),
938 938 parents)
939 939 )
940 940
941 941 # Check nodes marked as removed
942 942 if self.removed and not parents:
943 943 raise NodeDoesNotExistError("Cannot remove node at %s as there "
944 944 "were no parents specified" % self.removed[0].path)
945 945 really_removed = set()
946 946 for p in parents:
947 947 for node in self.removed:
948 948 try:
949 949 p.get_node(node.path)
950 950 really_removed.add(node.path)
951 951 except ChangesetError:
952 952 pass
953 953 not_removed = list(set(node.path for node in self.removed) - really_removed)
954 954 if not_removed:
955 955 raise NodeDoesNotExistError("Cannot remove node at %s from "
956 956 "following parents: %s" % (not_removed[0], parents))
957 957
958 958 def commit(self, message, author, parents=None, branch=None, date=None,
959 959 **kwargs):
960 960 """
961 961 Performs in-memory commit (doesn't check workdir in any way) and
962 962 returns newly created ``Changeset``. Updates repository's
963 963 ``revisions``.
964 964
965 965 .. note::
966 966 While overriding this method each backend's should call
967 967 ``self.check_integrity(parents)`` in the first place.
968 968
969 969 :param message: message of the commit
970 970 :param author: full username, i.e. "Joe Doe <joe.doe@example.com>"
971 971 :param parents: single parent or sequence of parents from which commit
972 972 would be derived
973 973 :param date: ``datetime.datetime`` instance. Defaults to
974 974 ``datetime.datetime.now()``.
975 975 :param branch: branch name, as string. If none given, default backend's
976 976 branch would be used.
977 977
978 978 :raises ``CommitError``: if any error occurs while committing
979 979 """
980 980 raise NotImplementedError
981 981
982 982
983 983 class EmptyChangeset(BaseChangeset):
984 984 """
985 985 An dummy empty changeset. It's possible to pass hash when creating
986 986 an EmptyChangeset
987 987 """
988 988
989 989 def __init__(self, cs='0' * 40, repo=None, requested_revision=None,
990 990 alias=None, revision=-1, message='', author='', date=None):
991 991 self._empty_cs = cs
992 992 self.revision = revision
993 993 self.message = message
994 994 self.author = author
995 995 self.date = date or datetime.datetime.fromtimestamp(0)
996 996 self.repository = repo
997 997 self.requested_revision = requested_revision
998 998 self.alias = alias
999 999
1000 1000 @LazyProperty
1001 1001 def raw_id(self):
1002 1002 """
1003 1003 Returns raw string identifying this changeset, useful for web
1004 1004 representation.
1005 1005 """
1006 1006
1007 1007 return self._empty_cs
1008 1008
1009 1009 @LazyProperty
1010 1010 def branch(self):
1011 1011 from kallithea.lib.vcs.backends import get_backend
1012 1012 return get_backend(self.alias).DEFAULT_BRANCH_NAME
1013 1013
1014 1014 @LazyProperty
1015 1015 def branches(self):
1016 1016 from kallithea.lib.vcs.backends import get_backend
1017 1017 return [get_backend(self.alias).DEFAULT_BRANCH_NAME]
1018 1018
1019 1019 @LazyProperty
1020 1020 def short_id(self):
1021 1021 return self.raw_id[:12]
1022 1022
1023 1023 def get_file_changeset(self, path):
1024 1024 return self
1025 1025
1026 1026 def get_file_content(self, path):
1027 1027 return ''
1028 1028
1029 1029 def get_file_size(self, path):
1030 1030 return 0
1031 1031
1032 1032
1033 1033 class CollectionGenerator(object):
1034 1034
1035 1035 def __init__(self, repo, revs):
1036 1036 self.repo = repo
1037 1037 self.revs = revs
1038 1038
1039 1039 def __len__(self):
1040 1040 return len(self.revs)
1041 1041
1042 1042 def __iter__(self):
1043 1043 for rev in self.revs:
1044 1044 yield self.repo.get_changeset(rev)
1045 1045
1046 1046 def __getitem__(self, what):
1047 1047 """Return either a single element by index, or a sliced collection."""
1048 1048 if isinstance(what, slice):
1049 1049 return CollectionGenerator(self.repo, self.revs[what])
1050 1050 else:
1051 1051 # single item
1052 1052 return self.repo.get_changeset(self.revs[what])
1053 1053
1054 1054 def __repr__(self):
1055 1055 return '<CollectionGenerator[len:%s]>' % (len(self))
@@ -1,541 +1,541 b''
1 1 import re
2 2 from io import BytesIO
3 3 from itertools import chain
4 4 from subprocess import PIPE, Popen
5 5
6 6 from dulwich import objects
7 7 from dulwich.config import ConfigFile
8 8
9 9 from kallithea.lib.vcs.backends.base import BaseChangeset, EmptyChangeset
10 10 from kallithea.lib.vcs.conf import settings
11 11 from kallithea.lib.vcs.exceptions import ChangesetDoesNotExistError, ChangesetError, ImproperArchiveTypeError, NodeDoesNotExistError, RepositoryError, VCSError
12 from kallithea.lib.vcs.nodes import (
13 AddedFileNodesGenerator, ChangedFileNodesGenerator, DirNode, FileNode, NodeKind, RemovedFileNodesGenerator, RootNode, SubModuleNode)
12 from kallithea.lib.vcs.nodes import (AddedFileNodesGenerator, ChangedFileNodesGenerator, DirNode, FileNode, NodeKind, RemovedFileNodesGenerator, RootNode,
13 SubModuleNode)
14 14 from kallithea.lib.vcs.utils import ascii_bytes, ascii_str, date_fromtimestamp, safe_int, safe_str
15 15 from kallithea.lib.vcs.utils.lazy import LazyProperty
16 16
17 17
18 18 class GitChangeset(BaseChangeset):
19 19 """
20 20 Represents state of the repository at a revision.
21 21 """
22 22
23 23 def __init__(self, repository, revision):
24 24 self._stat_modes = {}
25 25 self.repository = repository
26 26 try:
27 27 commit = self.repository._repo[ascii_bytes(revision)]
28 28 if isinstance(commit, objects.Tag):
29 29 revision = safe_str(commit.object[1])
30 30 commit = self.repository._repo.get_object(commit.object[1])
31 31 except KeyError:
32 32 raise RepositoryError("Cannot get object with id %s" % revision)
33 33 self.raw_id = ascii_str(commit.id)
34 34 self.short_id = self.raw_id[:12]
35 35 self._commit = commit # a Dulwich Commmit with .id
36 36 self._tree_id = commit.tree
37 37 self._committer_property = 'committer'
38 38 self._author_property = 'author'
39 39 self._date_property = 'commit_time'
40 40 self._date_tz_property = 'commit_timezone'
41 41 self.revision = repository.revisions.index(self.raw_id)
42 42
43 43 self.nodes = {}
44 44 self._paths = {}
45 45
46 46 @LazyProperty
47 47 def bookmarks(self):
48 48 return ()
49 49
50 50 @LazyProperty
51 51 def message(self):
52 52 return safe_str(self._commit.message)
53 53
54 54 @LazyProperty
55 55 def committer(self):
56 56 return safe_str(getattr(self._commit, self._committer_property))
57 57
58 58 @LazyProperty
59 59 def author(self):
60 60 return safe_str(getattr(self._commit, self._author_property))
61 61
62 62 @LazyProperty
63 63 def date(self):
64 64 return date_fromtimestamp(getattr(self._commit, self._date_property),
65 65 getattr(self._commit, self._date_tz_property))
66 66
67 67 @LazyProperty
68 68 def _timestamp(self):
69 69 return getattr(self._commit, self._date_property)
70 70
71 71 @LazyProperty
72 72 def status(self):
73 73 """
74 74 Returns modified, added, removed, deleted files for current changeset
75 75 """
76 76 return self.changed, self.added, self.removed
77 77
78 78 @LazyProperty
79 79 def tags(self):
80 80 _tags = []
81 81 for tname, tsha in self.repository.tags.items():
82 82 if tsha == self.raw_id:
83 83 _tags.append(tname)
84 84 return _tags
85 85
86 86 @LazyProperty
87 87 def branch(self):
88 88 # Note: This function will return one branch name for the changeset -
89 89 # that might not make sense in Git where branches() is a better match
90 90 # for the basic model
91 91 heads = self.repository._heads(reverse=False)
92 92 ref = heads.get(self._commit.id)
93 93 if ref:
94 94 return safe_str(ref)
95 95
96 96 @LazyProperty
97 97 def branches(self):
98 98 heads = self.repository._heads(reverse=True)
99 99 return [safe_str(b) for b in heads if heads[b] == self._commit.id] # FIXME: Inefficient ... and returning None!
100 100
101 101 def _get_id_for_path(self, path):
102 102 # FIXME: Please, spare a couple of minutes and make those codes cleaner;
103 103 if path not in self._paths:
104 104 path = path.strip('/')
105 105 # set root tree
106 106 tree = self.repository._repo[self._tree_id]
107 107 if path == '':
108 108 self._paths[''] = tree.id
109 109 return tree.id
110 110 splitted = path.split('/')
111 111 dirs, name = splitted[:-1], splitted[-1]
112 112 curdir = ''
113 113
114 114 # initially extract things from root dir
115 115 for item, stat, id in tree.items():
116 116 name = safe_str(item)
117 117 if curdir:
118 118 name = '/'.join((curdir, name))
119 119 self._paths[name] = id
120 120 self._stat_modes[name] = stat
121 121
122 122 for dir in dirs:
123 123 if curdir:
124 124 curdir = '/'.join((curdir, dir))
125 125 else:
126 126 curdir = dir
127 127 dir_id = None
128 128 for item, stat, id in tree.items():
129 129 name = safe_str(item)
130 130 if dir == name:
131 131 dir_id = id
132 132 if dir_id:
133 133 # Update tree
134 134 tree = self.repository._repo[dir_id]
135 135 if not isinstance(tree, objects.Tree):
136 136 raise ChangesetError('%s is not a directory' % curdir)
137 137 else:
138 138 raise ChangesetError('%s have not been found' % curdir)
139 139
140 140 # cache all items from the given traversed tree
141 141 for item, stat, id in tree.items():
142 142 name = safe_str(item)
143 143 if curdir:
144 144 name = '/'.join((curdir, name))
145 145 self._paths[name] = id
146 146 self._stat_modes[name] = stat
147 147 if path not in self._paths:
148 148 raise NodeDoesNotExistError("There is no file nor directory "
149 149 "at the given path '%s' at revision %s"
150 150 % (path, self.short_id))
151 151 return self._paths[path]
152 152
153 153 def _get_kind(self, path):
154 154 obj = self.repository._repo[self._get_id_for_path(path)]
155 155 if isinstance(obj, objects.Blob):
156 156 return NodeKind.FILE
157 157 elif isinstance(obj, objects.Tree):
158 158 return NodeKind.DIR
159 159
160 160 def _get_filectx(self, path):
161 161 path = path.rstrip('/')
162 162 if self._get_kind(path) != NodeKind.FILE:
163 163 raise ChangesetError("File does not exist for revision %s at "
164 164 " '%s'" % (self.raw_id, path))
165 165 return path
166 166
167 167 def _get_file_nodes(self):
168 168 return chain(*(t[2] for t in self.walk()))
169 169
170 170 @LazyProperty
171 171 def parents(self):
172 172 """
173 173 Returns list of parents changesets.
174 174 """
175 175 return [self.repository.get_changeset(ascii_str(parent_id))
176 176 for parent_id in self._commit.parents]
177 177
178 178 @LazyProperty
179 179 def children(self):
180 180 """
181 181 Returns list of children changesets.
182 182 """
183 183 rev_filter = settings.GIT_REV_FILTER
184 184 so = self.repository.run_git_command(
185 185 ['rev-list', rev_filter, '--children']
186 186 )
187 187 return [
188 188 self.repository.get_changeset(cs)
189 189 for parts in (l.split(' ') for l in so.splitlines())
190 190 if parts[0] == self.raw_id
191 191 for cs in parts[1:]
192 192 ]
193 193
194 194 def next(self, branch=None):
195 195 if branch and self.branch != branch:
196 196 raise VCSError('Branch option used on changeset not belonging '
197 197 'to that branch')
198 198
199 199 cs = self
200 200 while True:
201 201 try:
202 202 next_ = cs.revision + 1
203 203 next_rev = cs.repository.revisions[next_]
204 204 except IndexError:
205 205 raise ChangesetDoesNotExistError
206 206 cs = cs.repository.get_changeset(next_rev)
207 207
208 208 if not branch or branch == cs.branch:
209 209 return cs
210 210
211 211 def prev(self, branch=None):
212 212 if branch and self.branch != branch:
213 213 raise VCSError('Branch option used on changeset not belonging '
214 214 'to that branch')
215 215
216 216 cs = self
217 217 while True:
218 218 try:
219 219 prev_ = cs.revision - 1
220 220 if prev_ < 0:
221 221 raise IndexError
222 222 prev_rev = cs.repository.revisions[prev_]
223 223 except IndexError:
224 224 raise ChangesetDoesNotExistError
225 225 cs = cs.repository.get_changeset(prev_rev)
226 226
227 227 if not branch or branch == cs.branch:
228 228 return cs
229 229
230 230 def diff(self, ignore_whitespace=True, context=3):
231 231 # Only used to feed diffstat
232 232 rev1 = self.parents[0] if self.parents else self.repository.EMPTY_CHANGESET
233 233 rev2 = self
234 234 return b''.join(self.repository.get_diff(rev1, rev2,
235 235 ignore_whitespace=ignore_whitespace,
236 236 context=context))
237 237
238 238 def get_file_mode(self, path):
239 239 """
240 240 Returns stat mode of the file at the given ``path``.
241 241 """
242 242 # ensure path is traversed
243 243 self._get_id_for_path(path)
244 244 return self._stat_modes[path]
245 245
246 246 def get_file_content(self, path):
247 247 """
248 248 Returns content of the file at given ``path``.
249 249 """
250 250 id = self._get_id_for_path(path)
251 251 blob = self.repository._repo[id]
252 252 return blob.as_pretty_string()
253 253
254 254 def get_file_size(self, path):
255 255 """
256 256 Returns size of the file at given ``path``.
257 257 """
258 258 id = self._get_id_for_path(path)
259 259 blob = self.repository._repo[id]
260 260 return blob.raw_length()
261 261
262 262 def get_file_changeset(self, path):
263 263 """
264 264 Returns last commit of the file at the given ``path``.
265 265 """
266 266 return self.get_file_history(path, limit=1)[0]
267 267
268 268 def get_file_history(self, path, limit=None):
269 269 """
270 270 Returns history of file as reversed list of ``Changeset`` objects for
271 271 which file at given ``path`` has been modified.
272 272
273 273 TODO: This function now uses os underlying 'git' and 'grep' commands
274 274 which is generally not good. Should be replaced with algorithm
275 275 iterating commits.
276 276 """
277 277 self._get_filectx(path)
278 278
279 279 if limit is not None:
280 280 cmd = ['log', '-n', str(safe_int(limit, 0)),
281 281 '--pretty=format:%H', '-s', self.raw_id, '--', path]
282 282
283 283 else:
284 284 cmd = ['log',
285 285 '--pretty=format:%H', '-s', self.raw_id, '--', path]
286 286 so = self.repository.run_git_command(cmd)
287 287 ids = re.findall(r'[0-9a-fA-F]{40}', so)
288 288 return [self.repository.get_changeset(sha) for sha in ids]
289 289
290 290 def get_file_history_2(self, path):
291 291 """
292 292 Returns history of file as reversed list of ``Changeset`` objects for
293 293 which file at given ``path`` has been modified.
294 294
295 295 """
296 296 self._get_filectx(path)
297 297 from dulwich.walk import Walker
298 298 include = [self.raw_id]
299 299 walker = Walker(self.repository._repo.object_store, include,
300 300 paths=[path], max_entries=1)
301 301 return [self.repository.get_changeset(ascii_str(x.commit.id.decode))
302 302 for x in walker]
303 303
304 304 def get_file_annotate(self, path):
305 305 """
306 306 Returns a generator of four element tuples with
307 307 lineno, sha, changeset lazy loader and line
308 308 """
309 309 # TODO: This function now uses os underlying 'git' command which is
310 310 # generally not good. Should be replaced with algorithm iterating
311 311 # commits.
312 312 cmd = ['blame', '-l', '--root', '-r', self.raw_id, '--', path]
313 313 # -l ==> outputs long shas (and we need all 40 characters)
314 314 # --root ==> doesn't put '^' character for boundaries
315 315 # -r sha ==> blames for the given revision
316 316 so = self.repository.run_git_command(cmd)
317 317
318 318 for i, blame_line in enumerate(so.split('\n')[:-1]):
319 319 sha, line = re.split(r' ', blame_line, 1)
320 320 yield (i + 1, sha, lambda sha=sha: self.repository.get_changeset(sha), line)
321 321
322 322 def fill_archive(self, stream=None, kind='tgz', prefix=None,
323 323 subrepos=False):
324 324 """
325 325 Fills up given stream.
326 326
327 327 :param stream: file like object.
328 328 :param kind: one of following: ``zip``, ``tgz`` or ``tbz2``.
329 329 Default: ``tgz``.
330 330 :param prefix: name of root directory in archive.
331 331 Default is repository name and changeset's raw_id joined with dash
332 332 (``repo-tip.<KIND>``).
333 333 :param subrepos: include subrepos in this archive.
334 334
335 335 :raise ImproperArchiveTypeError: If given kind is wrong.
336 336 :raise VcsError: If given stream is None
337 337 """
338 338 allowed_kinds = settings.ARCHIVE_SPECS
339 339 if kind not in allowed_kinds:
340 340 raise ImproperArchiveTypeError('Archive kind not supported use one'
341 341 'of %s' % ' '.join(allowed_kinds))
342 342
343 343 if stream is None:
344 344 raise VCSError('You need to pass in a valid stream for filling'
345 345 ' with archival data')
346 346
347 347 if prefix is None:
348 348 prefix = '%s-%s' % (self.repository.name, self.short_id)
349 349 elif prefix.startswith('/'):
350 350 raise VCSError("Prefix cannot start with leading slash")
351 351 elif prefix.strip() == '':
352 352 raise VCSError("Prefix cannot be empty")
353 353
354 354 if kind == 'zip':
355 355 frmt = 'zip'
356 356 else:
357 357 frmt = 'tar'
358 358 _git_path = settings.GIT_EXECUTABLE_PATH
359 359 cmd = '%s archive --format=%s --prefix=%s/ %s' % (_git_path,
360 360 frmt, prefix, self.raw_id)
361 361 if kind == 'tgz':
362 362 cmd += ' | gzip -9'
363 363 elif kind == 'tbz2':
364 364 cmd += ' | bzip2 -9'
365 365
366 366 if stream is None:
367 367 raise VCSError('You need to pass in a valid stream for filling'
368 368 ' with archival data')
369 369 popen = Popen(cmd, stdout=PIPE, stderr=PIPE, shell=True,
370 370 cwd=self.repository.path)
371 371
372 372 buffer_size = 1024 * 8
373 373 chunk = popen.stdout.read(buffer_size)
374 374 while chunk:
375 375 stream.write(chunk)
376 376 chunk = popen.stdout.read(buffer_size)
377 377 # Make sure all descriptors would be read
378 378 popen.communicate()
379 379
380 380 def get_nodes(self, path):
381 381 """
382 382 Returns combined ``DirNode`` and ``FileNode`` objects list representing
383 383 state of changeset at the given ``path``. If node at the given ``path``
384 384 is not instance of ``DirNode``, ChangesetError would be raised.
385 385 """
386 386
387 387 if self._get_kind(path) != NodeKind.DIR:
388 388 raise ChangesetError("Directory does not exist for revision %s at "
389 389 " '%s'" % (self.revision, path))
390 390 path = path.rstrip('/')
391 391 id = self._get_id_for_path(path)
392 392 tree = self.repository._repo[id]
393 393 dirnodes = []
394 394 filenodes = []
395 395 als = self.repository.alias
396 396 for name, stat, id in tree.items():
397 397 obj_path = safe_str(name)
398 398 if path != '':
399 399 obj_path = '/'.join((path, obj_path))
400 400 if objects.S_ISGITLINK(stat):
401 401 root_tree = self.repository._repo[self._tree_id]
402 402 cf = ConfigFile.from_file(BytesIO(self.repository._repo.get_object(root_tree[b'.gitmodules'][1]).data))
403 403 url = ascii_str(cf.get(('submodule', obj_path), 'url'))
404 404 dirnodes.append(SubModuleNode(obj_path, url=url, changeset=ascii_str(id),
405 405 alias=als))
406 406 continue
407 407
408 408 obj = self.repository._repo.get_object(id)
409 409 if obj_path not in self._stat_modes:
410 410 self._stat_modes[obj_path] = stat
411 411 if isinstance(obj, objects.Tree):
412 412 dirnodes.append(DirNode(obj_path, changeset=self))
413 413 elif isinstance(obj, objects.Blob):
414 414 filenodes.append(FileNode(obj_path, changeset=self, mode=stat))
415 415 else:
416 416 raise ChangesetError("Requested object should be Tree "
417 417 "or Blob, is %r" % type(obj))
418 418 nodes = dirnodes + filenodes
419 419 for node in nodes:
420 420 if node.path not in self.nodes:
421 421 self.nodes[node.path] = node
422 422 nodes.sort()
423 423 return nodes
424 424
425 425 def get_node(self, path):
426 426 """
427 427 Returns ``Node`` object from the given ``path``. If there is no node at
428 428 the given ``path``, ``ChangesetError`` would be raised.
429 429 """
430 430 path = path.rstrip('/')
431 431 if path not in self.nodes:
432 432 try:
433 433 id_ = self._get_id_for_path(path)
434 434 except ChangesetError:
435 435 raise NodeDoesNotExistError("Cannot find one of parents' "
436 436 "directories for a given path: %s" % path)
437 437
438 438 stat = self._stat_modes.get(path)
439 439 if stat and objects.S_ISGITLINK(stat):
440 440 tree = self.repository._repo[self._tree_id]
441 441 cf = ConfigFile.from_file(BytesIO(self.repository._repo.get_object(tree[b'.gitmodules'][1]).data))
442 442 url = ascii_str(cf.get(('submodule', path), 'url'))
443 443 node = SubModuleNode(path, url=url, changeset=ascii_str(id_),
444 444 alias=self.repository.alias)
445 445 else:
446 446 obj = self.repository._repo.get_object(id_)
447 447
448 448 if isinstance(obj, objects.Tree):
449 449 if path == '':
450 450 node = RootNode(changeset=self)
451 451 else:
452 452 node = DirNode(path, changeset=self)
453 453 node._tree = obj
454 454 elif isinstance(obj, objects.Blob):
455 455 node = FileNode(path, changeset=self)
456 456 node._blob = obj
457 457 else:
458 458 raise NodeDoesNotExistError("There is no file nor directory "
459 459 "at the given path: '%s' at revision %s"
460 460 % (path, self.short_id))
461 461 # cache node
462 462 self.nodes[path] = node
463 463 return self.nodes[path]
464 464
465 465 @LazyProperty
466 466 def affected_files(self):
467 467 """
468 468 Gets a fast accessible file changes for given changeset
469 469 """
470 470 added, modified, deleted = self._changes_cache
471 471 return list(added.union(modified).union(deleted))
472 472
473 473 @LazyProperty
474 474 def _changes_cache(self):
475 475 added = set()
476 476 modified = set()
477 477 deleted = set()
478 478 _r = self.repository._repo
479 479
480 480 parents = self.parents
481 481 if not self.parents:
482 482 parents = [EmptyChangeset()]
483 483 for parent in parents:
484 484 if isinstance(parent, EmptyChangeset):
485 485 oid = None
486 486 else:
487 487 oid = _r[parent._commit.id].tree
488 488 changes = _r.object_store.tree_changes(oid, _r[self._commit.id].tree)
489 489 for (oldpath, newpath), (_, _), (_, _) in changes:
490 490 if newpath and oldpath:
491 491 modified.add(safe_str(newpath))
492 492 elif newpath and not oldpath:
493 493 added.add(safe_str(newpath))
494 494 elif not newpath and oldpath:
495 495 deleted.add(safe_str(oldpath))
496 496 return added, modified, deleted
497 497
498 498 def _get_paths_for_status(self, status):
499 499 """
500 500 Returns sorted list of paths for given ``status``.
501 501
502 502 :param status: one of: *added*, *modified* or *deleted*
503 503 """
504 504 added, modified, deleted = self._changes_cache
505 505 return sorted({
506 506 'added': list(added),
507 507 'modified': list(modified),
508 508 'deleted': list(deleted)}[status]
509 509 )
510 510
511 511 @LazyProperty
512 512 def added(self):
513 513 """
514 514 Returns list of added ``FileNode`` objects.
515 515 """
516 516 if not self.parents:
517 517 return list(self._get_file_nodes())
518 518 return AddedFileNodesGenerator([n for n in
519 519 self._get_paths_for_status('added')], self)
520 520
521 521 @LazyProperty
522 522 def changed(self):
523 523 """
524 524 Returns list of modified ``FileNode`` objects.
525 525 """
526 526 if not self.parents:
527 527 return []
528 528 return ChangedFileNodesGenerator([n for n in
529 529 self._get_paths_for_status('modified')], self)
530 530
531 531 @LazyProperty
532 532 def removed(self):
533 533 """
534 534 Returns list of removed ``FileNode`` objects.
535 535 """
536 536 if not self.parents:
537 537 return []
538 538 return RemovedFileNodesGenerator([n for n in
539 539 self._get_paths_for_status('deleted')], self)
540 540
541 541 extra = {}
@@ -1,734 +1,734 b''
1 1 # -*- coding: utf-8 -*-
2 2 """
3 3 vcs.backends.git.repository
4 4 ~~~~~~~~~~~~~~~~~~~~~~~~~~~
5 5
6 6 Git repository implementation.
7 7
8 8 :created_on: Apr 8, 2010
9 9 :copyright: (c) 2010-2011 by Marcin Kuzminski, Lukasz Balcerzak.
10 10 """
11 11
12 12 import errno
13 13 import logging
14 14 import os
15 15 import re
16 16 import time
17 17 import urllib.error
18 18 import urllib.parse
19 19 import urllib.request
20 20 from collections import OrderedDict
21 21
22 22 import mercurial.url # import httpbasicauthhandler, httpdigestauthhandler
23 23 import mercurial.util # import url as hg_url
24 24 from dulwich.config import ConfigFile
25 25 from dulwich.objects import Tag
26 26 from dulwich.repo import NotGitRepository, Repo
27 27
28 28 from kallithea.lib.vcs import subprocessio
29 29 from kallithea.lib.vcs.backends.base import BaseRepository, CollectionGenerator
30 30 from kallithea.lib.vcs.conf import settings
31 from kallithea.lib.vcs.exceptions import (
32 BranchDoesNotExistError, ChangesetDoesNotExistError, EmptyRepositoryError, RepositoryError, TagAlreadyExistError, TagDoesNotExistError)
31 from kallithea.lib.vcs.exceptions import (BranchDoesNotExistError, ChangesetDoesNotExistError, EmptyRepositoryError, RepositoryError, TagAlreadyExistError,
32 TagDoesNotExistError)
33 33 from kallithea.lib.vcs.utils import ascii_str, date_fromtimestamp, makedate, safe_bytes, safe_str
34 34 from kallithea.lib.vcs.utils.lazy import LazyProperty
35 35 from kallithea.lib.vcs.utils.paths import abspath, get_user_home
36 36
37 37 from .changeset import GitChangeset
38 38 from .inmemory import GitInMemoryChangeset
39 39 from .workdir import GitWorkdir
40 40
41 41
42 42 SHA_PATTERN = re.compile(r'^([0-9a-fA-F]{12}|[0-9a-fA-F]{40})$')
43 43
44 44 log = logging.getLogger(__name__)
45 45
46 46
47 47 class GitRepository(BaseRepository):
48 48 """
49 49 Git repository backend.
50 50 """
51 51 DEFAULT_BRANCH_NAME = 'master'
52 52 scm = 'git'
53 53
54 54 def __init__(self, repo_path, create=False, src_url=None,
55 55 update_after_clone=False, bare=False):
56 56
57 57 self.path = abspath(repo_path)
58 58 self.repo = self._get_repo(create, src_url, update_after_clone, bare)
59 59 self.bare = self.repo.bare
60 60
61 61 @property
62 62 def _config_files(self):
63 63 return [
64 64 self.bare and abspath(self.path, 'config')
65 65 or abspath(self.path, '.git', 'config'),
66 66 abspath(get_user_home(), '.gitconfig'),
67 67 ]
68 68
69 69 @property
70 70 def _repo(self):
71 71 return self.repo
72 72
73 73 @property
74 74 def head(self):
75 75 try:
76 76 return self._repo.head()
77 77 except KeyError:
78 78 return None
79 79
80 80 @property
81 81 def _empty(self):
82 82 """
83 83 Checks if repository is empty ie. without any changesets
84 84 """
85 85
86 86 try:
87 87 self.revisions[0]
88 88 except (KeyError, IndexError):
89 89 return True
90 90 return False
91 91
92 92 @LazyProperty
93 93 def revisions(self):
94 94 """
95 95 Returns list of revisions' ids, in ascending order. Being lazy
96 96 attribute allows external tools to inject shas from cache.
97 97 """
98 98 return self._get_all_revisions()
99 99
100 100 @classmethod
101 101 def _run_git_command(cls, cmd, cwd=None):
102 102 """
103 103 Runs given ``cmd`` as git command and returns output bytes in a tuple
104 104 (stdout, stderr) ... or raise RepositoryError.
105 105
106 106 :param cmd: git command to be executed
107 107 :param cwd: passed directly to subprocess
108 108 """
109 109 # need to clean fix GIT_DIR !
110 110 gitenv = dict(os.environ)
111 111 gitenv.pop('GIT_DIR', None)
112 112 gitenv['GIT_CONFIG_NOGLOBAL'] = '1'
113 113
114 114 assert isinstance(cmd, list), cmd
115 115 cmd = [settings.GIT_EXECUTABLE_PATH, '-c', 'core.quotepath=false'] + cmd
116 116 try:
117 117 p = subprocessio.SubprocessIOChunker(cmd, cwd=cwd, env=gitenv, shell=False)
118 118 except (EnvironmentError, OSError) as err:
119 119 # output from the failing process is in str(EnvironmentError)
120 120 msg = ("Couldn't run git command %s.\n"
121 121 "Subprocess failed with '%s': %s\n" %
122 122 (cmd, type(err).__name__, err)
123 123 ).strip()
124 124 log.error(msg)
125 125 raise RepositoryError(msg)
126 126
127 127 try:
128 128 stdout = b''.join(p.output)
129 129 stderr = b''.join(p.error)
130 130 finally:
131 131 p.close()
132 132 # TODO: introduce option to make commands fail if they have any stderr output?
133 133 if stderr:
134 134 log.debug('stderr from %s:\n%s', cmd, stderr)
135 135 else:
136 136 log.debug('stderr from %s: None', cmd)
137 137 return stdout, stderr
138 138
139 139 def run_git_command(self, cmd):
140 140 """
141 141 Runs given ``cmd`` as git command with cwd set to current repo.
142 142 Returns stdout as unicode str ... or raise RepositoryError.
143 143 """
144 144 cwd = None
145 145 if os.path.isdir(self.path):
146 146 cwd = self.path
147 147 stdout, _stderr = self._run_git_command(cmd, cwd=cwd)
148 148 return safe_str(stdout)
149 149
150 150 @classmethod
151 151 def _check_url(cls, url):
152 152 """
153 153 Function will check given url and try to verify if it's a valid
154 154 link. Sometimes it may happened that git will issue basic
155 155 auth request that can cause whole API to hang when used from python
156 156 or other external calls.
157 157
158 158 On failures it'll raise urllib2.HTTPError, exception is also thrown
159 159 when the return code is non 200
160 160 """
161 161 # check first if it's not an local url
162 162 if os.path.isdir(url) or url.startswith('file:'):
163 163 return True
164 164
165 165 if url.startswith('git://'):
166 166 return True
167 167
168 168 if '+' in url[:url.find('://')]:
169 169 url = url[url.find('+') + 1:]
170 170
171 171 handlers = []
172 172 url_obj = mercurial.util.url(safe_bytes(url))
173 173 test_uri, authinfo = url_obj.authinfo()
174 174 if not test_uri.endswith(b'info/refs'):
175 175 test_uri = test_uri.rstrip(b'/') + b'/info/refs'
176 176
177 177 url_obj.passwd = b'*****'
178 178 cleaned_uri = str(url_obj)
179 179
180 180 if authinfo:
181 181 # create a password manager
182 182 passmgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
183 183 passmgr.add_password(*authinfo)
184 184
185 185 handlers.extend((mercurial.url.httpbasicauthhandler(passmgr),
186 186 mercurial.url.httpdigestauthhandler(passmgr)))
187 187
188 188 o = urllib.request.build_opener(*handlers)
189 189 o.addheaders = [('User-Agent', 'git/1.7.8.0')] # fake some git
190 190
191 191 req = urllib.request.Request(
192 192 "%s?%s" % (
193 193 test_uri,
194 194 urllib.parse.urlencode({"service": 'git-upload-pack'})
195 195 ))
196 196
197 197 try:
198 198 resp = o.open(req)
199 199 if resp.code != 200:
200 200 raise Exception('Return Code is not 200')
201 201 except Exception as e:
202 202 # means it cannot be cloned
203 203 raise urllib.error.URLError("[%s] org_exc: %s" % (cleaned_uri, e))
204 204
205 205 # now detect if it's proper git repo
206 206 gitdata = resp.read()
207 207 if b'service=git-upload-pack' not in gitdata:
208 208 raise urllib.error.URLError(
209 209 "url [%s] does not look like an git" % cleaned_uri)
210 210
211 211 return True
212 212
213 213 def _get_repo(self, create, src_url=None, update_after_clone=False,
214 214 bare=False):
215 215 if create and os.path.exists(self.path):
216 216 raise RepositoryError("Location already exist")
217 217 if src_url and not create:
218 218 raise RepositoryError("Create should be set to True if src_url is "
219 219 "given (clone operation creates repository)")
220 220 try:
221 221 if create and src_url:
222 222 GitRepository._check_url(src_url)
223 223 self.clone(src_url, update_after_clone, bare)
224 224 return Repo(self.path)
225 225 elif create:
226 226 os.makedirs(self.path)
227 227 if bare:
228 228 return Repo.init_bare(self.path)
229 229 else:
230 230 return Repo.init(self.path)
231 231 else:
232 232 return Repo(self.path)
233 233 except (NotGitRepository, OSError) as err:
234 234 raise RepositoryError(err)
235 235
236 236 def _get_all_revisions(self):
237 237 # we must check if this repo is not empty, since later command
238 238 # fails if it is. And it's cheaper to ask than throw the subprocess
239 239 # errors
240 240 try:
241 241 self._repo.head()
242 242 except KeyError:
243 243 return []
244 244
245 245 rev_filter = settings.GIT_REV_FILTER
246 246 cmd = ['rev-list', rev_filter, '--reverse', '--date-order']
247 247 try:
248 248 so = self.run_git_command(cmd)
249 249 except RepositoryError:
250 250 # Can be raised for empty repositories
251 251 return []
252 252 return so.splitlines()
253 253
254 254 def _get_all_revisions2(self):
255 255 # alternate implementation using dulwich
256 256 includes = [ascii_str(sha) for key, (sha, type_) in self._parsed_refs.items()
257 257 if type_ != b'T']
258 258 return [c.commit.id for c in self._repo.get_walker(include=includes)]
259 259
260 260 def _get_revision(self, revision):
261 261 """
262 262 Given any revision identifier, returns a 40 char string with revision hash.
263 263 """
264 264 if self._empty:
265 265 raise EmptyRepositoryError("There are no changesets yet")
266 266
267 267 if revision in (None, '', 'tip', 'HEAD', 'head', -1):
268 268 revision = -1
269 269
270 270 if isinstance(revision, int):
271 271 try:
272 272 return self.revisions[revision]
273 273 except IndexError:
274 274 msg = "Revision %r does not exist for %s" % (revision, self.name)
275 275 raise ChangesetDoesNotExistError(msg)
276 276
277 277 if isinstance(revision, str):
278 278 if revision.isdigit() and (len(revision) < 12 or len(revision) == revision.count('0')):
279 279 try:
280 280 return self.revisions[int(revision)]
281 281 except IndexError:
282 282 msg = "Revision %r does not exist for %s" % (revision, self)
283 283 raise ChangesetDoesNotExistError(msg)
284 284
285 285 # get by branch/tag name
286 286 _ref_revision = self._parsed_refs.get(safe_bytes(revision))
287 287 if _ref_revision: # and _ref_revision[1] in [b'H', b'RH', b'T']:
288 288 return ascii_str(_ref_revision[0])
289 289
290 290 if revision in self.revisions:
291 291 return revision
292 292
293 293 # maybe it's a tag ? we don't have them in self.revisions
294 294 if revision in self.tags.values():
295 295 return revision
296 296
297 297 if SHA_PATTERN.match(revision):
298 298 msg = "Revision %r does not exist for %s" % (revision, self.name)
299 299 raise ChangesetDoesNotExistError(msg)
300 300
301 301 raise ChangesetDoesNotExistError("Given revision %r not recognized" % revision)
302 302
303 303 def get_ref_revision(self, ref_type, ref_name):
304 304 """
305 305 Returns ``GitChangeset`` object representing repository's
306 306 changeset at the given ``revision``.
307 307 """
308 308 return self._get_revision(ref_name)
309 309
310 310 def _get_archives(self, archive_name='tip'):
311 311
312 312 for i in [('zip', '.zip'), ('gz', '.tar.gz'), ('bz2', '.tar.bz2')]:
313 313 yield {"type": i[0], "extension": i[1], "node": archive_name}
314 314
315 315 def _get_url(self, url):
316 316 """
317 317 Returns normalized url. If schema is not given, would fall to
318 318 filesystem (``file:///``) schema.
319 319 """
320 320 if url != 'default' and '://' not in url:
321 321 url = ':///'.join(('file', url))
322 322 return url
323 323
324 324 @LazyProperty
325 325 def name(self):
326 326 return os.path.basename(self.path)
327 327
328 328 @LazyProperty
329 329 def last_change(self):
330 330 """
331 331 Returns last change made on this repository as datetime object
332 332 """
333 333 return date_fromtimestamp(self._get_mtime(), makedate()[1])
334 334
335 335 def _get_mtime(self):
336 336 try:
337 337 return time.mktime(self.get_changeset().date.timetuple())
338 338 except RepositoryError:
339 339 idx_loc = '' if self.bare else '.git'
340 340 # fallback to filesystem
341 341 in_path = os.path.join(self.path, idx_loc, "index")
342 342 he_path = os.path.join(self.path, idx_loc, "HEAD")
343 343 if os.path.exists(in_path):
344 344 return os.stat(in_path).st_mtime
345 345 else:
346 346 return os.stat(he_path).st_mtime
347 347
348 348 @LazyProperty
349 349 def description(self):
350 350 return safe_str(self._repo.get_description() or b'unknown')
351 351
352 352 @LazyProperty
353 353 def contact(self):
354 354 undefined_contact = 'Unknown'
355 355 return undefined_contact
356 356
357 357 @property
358 358 def branches(self):
359 359 if not self.revisions:
360 360 return {}
361 361 _branches = [(safe_str(key), ascii_str(sha))
362 362 for key, (sha, type_) in self._parsed_refs.items() if type_ == b'H']
363 363 return OrderedDict(sorted(_branches, key=(lambda ctx: ctx[0]), reverse=False))
364 364
365 365 @LazyProperty
366 366 def closed_branches(self):
367 367 return {}
368 368
369 369 @LazyProperty
370 370 def tags(self):
371 371 return self._get_tags()
372 372
373 373 def _get_tags(self):
374 374 if not self.revisions:
375 375 return {}
376 376 _tags = [(safe_str(key), ascii_str(sha))
377 377 for key, (sha, type_) in self._parsed_refs.items() if type_ == b'T']
378 378 return OrderedDict(sorted(_tags, key=(lambda ctx: ctx[0]), reverse=True))
379 379
380 380 def tag(self, name, user, revision=None, message=None, date=None,
381 381 **kwargs):
382 382 """
383 383 Creates and returns a tag for the given ``revision``.
384 384
385 385 :param name: name for new tag
386 386 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
387 387 :param revision: changeset id for which new tag would be created
388 388 :param message: message of the tag's commit
389 389 :param date: date of tag's commit
390 390
391 391 :raises TagAlreadyExistError: if tag with same name already exists
392 392 """
393 393 if name in self.tags:
394 394 raise TagAlreadyExistError("Tag %s already exists" % name)
395 395 changeset = self.get_changeset(revision)
396 396 message = message or "Added tag %s for commit %s" % (name,
397 397 changeset.raw_id)
398 398 self._repo.refs[b"refs/tags/%s" % safe_bytes(name)] = changeset._commit.id
399 399
400 400 self._parsed_refs = self._get_parsed_refs()
401 401 self.tags = self._get_tags()
402 402 return changeset
403 403
404 404 def remove_tag(self, name, user, message=None, date=None):
405 405 """
406 406 Removes tag with the given ``name``.
407 407
408 408 :param name: name of the tag to be removed
409 409 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
410 410 :param message: message of the tag's removal commit
411 411 :param date: date of tag's removal commit
412 412
413 413 :raises TagDoesNotExistError: if tag with given name does not exists
414 414 """
415 415 if name not in self.tags:
416 416 raise TagDoesNotExistError("Tag %s does not exist" % name)
417 417 # self._repo.refs is a DiskRefsContainer, and .path gives the full absolute path of '.git'
418 418 tagpath = os.path.join(safe_str(self._repo.refs.path), 'refs', 'tags', name)
419 419 try:
420 420 os.remove(tagpath)
421 421 self._parsed_refs = self._get_parsed_refs()
422 422 self.tags = self._get_tags()
423 423 except OSError as e:
424 424 raise RepositoryError(e.strerror)
425 425
426 426 @LazyProperty
427 427 def bookmarks(self):
428 428 """
429 429 Gets bookmarks for this repository
430 430 """
431 431 return {}
432 432
433 433 @LazyProperty
434 434 def _parsed_refs(self):
435 435 return self._get_parsed_refs()
436 436
437 437 def _get_parsed_refs(self):
438 438 """Return refs as a dict, like:
439 439 { b'v0.2.0': [b'599ba911aa24d2981225f3966eb659dfae9e9f30', b'T'] }
440 440 """
441 441 _repo = self._repo
442 442 refs = _repo.get_refs()
443 443 keys = [(b'refs/heads/', b'H'),
444 444 (b'refs/remotes/origin/', b'RH'),
445 445 (b'refs/tags/', b'T')]
446 446 _refs = {}
447 447 for ref, sha in refs.items():
448 448 for k, type_ in keys:
449 449 if ref.startswith(k):
450 450 _key = ref[len(k):]
451 451 if type_ == b'T':
452 452 obj = _repo.get_object(sha)
453 453 if isinstance(obj, Tag):
454 454 sha = _repo.get_object(sha).object[1]
455 455 _refs[_key] = [sha, type_]
456 456 break
457 457 return _refs
458 458
459 459 def _heads(self, reverse=False):
460 460 refs = self._repo.get_refs()
461 461 heads = {}
462 462
463 463 for key, val in refs.items():
464 464 for ref_key in [b'refs/heads/', b'refs/remotes/origin/']:
465 465 if key.startswith(ref_key):
466 466 n = key[len(ref_key):]
467 467 if n not in [b'HEAD']:
468 468 heads[n] = val
469 469
470 470 return heads if reverse else dict((y, x) for x, y in heads.items())
471 471
472 472 def get_changeset(self, revision=None):
473 473 """
474 474 Returns ``GitChangeset`` object representing commit from git repository
475 475 at the given revision or head (most recent commit) if None given.
476 476 """
477 477 if isinstance(revision, GitChangeset):
478 478 return revision
479 479 return GitChangeset(repository=self, revision=self._get_revision(revision))
480 480
481 481 def get_changesets(self, start=None, end=None, start_date=None,
482 482 end_date=None, branch_name=None, reverse=False, max_revisions=None):
483 483 """
484 484 Returns iterator of ``GitChangeset`` objects from start to end (both
485 485 are inclusive), in ascending date order (unless ``reverse`` is set).
486 486
487 487 :param start: changeset ID, as str; first returned changeset
488 488 :param end: changeset ID, as str; last returned changeset
489 489 :param start_date: if specified, changesets with commit date less than
490 490 ``start_date`` would be filtered out from returned set
491 491 :param end_date: if specified, changesets with commit date greater than
492 492 ``end_date`` would be filtered out from returned set
493 493 :param branch_name: if specified, changesets not reachable from given
494 494 branch would be filtered out from returned set
495 495 :param reverse: if ``True``, returned generator would be reversed
496 496 (meaning that returned changesets would have descending date order)
497 497
498 498 :raise BranchDoesNotExistError: If given ``branch_name`` does not
499 499 exist.
500 500 :raise ChangesetDoesNotExistError: If changeset for given ``start`` or
501 501 ``end`` could not be found.
502 502
503 503 """
504 504 if branch_name and branch_name not in self.branches:
505 505 raise BranchDoesNotExistError("Branch '%s' not found"
506 506 % branch_name)
507 507 # actually we should check now if it's not an empty repo to not spaw
508 508 # subprocess commands
509 509 if self._empty:
510 510 raise EmptyRepositoryError("There are no changesets yet")
511 511
512 512 # %H at format means (full) commit hash, initial hashes are retrieved
513 513 # in ascending date order
514 514 cmd = ['log', '--date-order', '--reverse', '--pretty=format:%H']
515 515 if max_revisions:
516 516 cmd += ['--max-count=%s' % max_revisions]
517 517 if start_date:
518 518 cmd += ['--since', start_date.strftime('%m/%d/%y %H:%M:%S')]
519 519 if end_date:
520 520 cmd += ['--until', end_date.strftime('%m/%d/%y %H:%M:%S')]
521 521 if branch_name:
522 522 cmd.append(branch_name)
523 523 else:
524 524 cmd.append(settings.GIT_REV_FILTER)
525 525
526 526 revs = self.run_git_command(cmd).splitlines()
527 527 start_pos = 0
528 528 end_pos = len(revs)
529 529 if start:
530 530 _start = self._get_revision(start)
531 531 try:
532 532 start_pos = revs.index(_start)
533 533 except ValueError:
534 534 pass
535 535
536 536 if end is not None:
537 537 _end = self._get_revision(end)
538 538 try:
539 539 end_pos = revs.index(_end)
540 540 except ValueError:
541 541 pass
542 542
543 543 if None not in [start, end] and start_pos > end_pos:
544 544 raise RepositoryError('start cannot be after end')
545 545
546 546 if end_pos is not None:
547 547 end_pos += 1
548 548
549 549 revs = revs[start_pos:end_pos]
550 550 if reverse:
551 551 revs.reverse()
552 552
553 553 return CollectionGenerator(self, revs)
554 554
555 555 def get_diff(self, rev1, rev2, path=None, ignore_whitespace=False,
556 556 context=3):
557 557 """
558 558 Returns (git like) *diff*, as plain bytes text. Shows changes
559 559 introduced by ``rev2`` since ``rev1``.
560 560
561 561 :param rev1: Entry point from which diff is shown. Can be
562 562 ``self.EMPTY_CHANGESET`` - in this case, patch showing all
563 563 the changes since empty state of the repository until ``rev2``
564 564 :param rev2: Until which revision changes should be shown.
565 565 :param ignore_whitespace: If set to ``True``, would not show whitespace
566 566 changes. Defaults to ``False``.
567 567 :param context: How many lines before/after changed lines should be
568 568 shown. Defaults to ``3``. Due to limitations in Git, if
569 569 value passed-in is greater than ``2**31-1``
570 570 (``2147483647``), it will be set to ``2147483647``
571 571 instead. If negative value is passed-in, it will be set to
572 572 ``0`` instead.
573 573 """
574 574
575 575 # Git internally uses a signed long int for storing context
576 576 # size (number of lines to show before and after the
577 577 # differences). This can result in integer overflow, so we
578 578 # ensure the requested context is smaller by one than the
579 579 # number that would cause the overflow. It is highly unlikely
580 580 # that a single file will contain that many lines, so this
581 581 # kind of change should not cause any realistic consequences.
582 582 overflowed_long_int = 2**31
583 583
584 584 if context >= overflowed_long_int:
585 585 context = overflowed_long_int - 1
586 586
587 587 # Negative context values make no sense, and will result in
588 588 # errors. Ensure this does not happen.
589 589 if context < 0:
590 590 context = 0
591 591
592 592 flags = ['-U%s' % context, '--full-index', '--binary', '-p', '-M', '--abbrev=40']
593 593 if ignore_whitespace:
594 594 flags.append('-w')
595 595
596 596 if hasattr(rev1, 'raw_id'):
597 597 rev1 = getattr(rev1, 'raw_id')
598 598
599 599 if hasattr(rev2, 'raw_id'):
600 600 rev2 = getattr(rev2, 'raw_id')
601 601
602 602 if rev1 == self.EMPTY_CHANGESET:
603 603 rev2 = self.get_changeset(rev2).raw_id
604 604 cmd = ['show'] + flags + [rev2]
605 605 else:
606 606 rev1 = self.get_changeset(rev1).raw_id
607 607 rev2 = self.get_changeset(rev2).raw_id
608 608 cmd = ['diff'] + flags + [rev1, rev2]
609 609
610 610 if path:
611 611 cmd += ['--', path]
612 612
613 613 stdout, stderr = self._run_git_command(cmd, cwd=self.path)
614 614 # If we used 'show' command, strip first few lines (until actual diff
615 615 # starts)
616 616 if rev1 == self.EMPTY_CHANGESET:
617 617 parts = stdout.split(b'\ndiff ', 1)
618 618 if len(parts) > 1:
619 619 stdout = b'diff ' + parts[1]
620 620 return stdout
621 621
622 622 @LazyProperty
623 623 def in_memory_changeset(self):
624 624 """
625 625 Returns ``GitInMemoryChangeset`` object for this repository.
626 626 """
627 627 return GitInMemoryChangeset(self)
628 628
629 629 def clone(self, url, update_after_clone=True, bare=False):
630 630 """
631 631 Tries to clone changes from external location.
632 632
633 633 :param update_after_clone: If set to ``False``, git won't checkout
634 634 working directory
635 635 :param bare: If set to ``True``, repository would be cloned into
636 636 *bare* git repository (no working directory at all).
637 637 """
638 638 url = self._get_url(url)
639 639 cmd = ['clone', '-q']
640 640 if bare:
641 641 cmd.append('--bare')
642 642 elif not update_after_clone:
643 643 cmd.append('--no-checkout')
644 644 cmd += ['--', url, self.path]
645 645 # If error occurs run_git_command raises RepositoryError already
646 646 self.run_git_command(cmd)
647 647
648 648 def pull(self, url):
649 649 """
650 650 Tries to pull changes from external location.
651 651 """
652 652 url = self._get_url(url)
653 653 cmd = ['pull', '--ff-only', url]
654 654 # If error occurs run_git_command raises RepositoryError already
655 655 self.run_git_command(cmd)
656 656
657 657 def fetch(self, url):
658 658 """
659 659 Tries to pull changes from external location.
660 660 """
661 661 url = self._get_url(url)
662 662 so = self.run_git_command(['ls-remote', '-h', url])
663 663 cmd = ['fetch', url, '--']
664 664 for line in (x for x in so.splitlines()):
665 665 sha, ref = line.split('\t')
666 666 cmd.append('+%s:%s' % (ref, ref))
667 667 self.run_git_command(cmd)
668 668
669 669 def _update_server_info(self):
670 670 """
671 671 runs gits update-server-info command in this repo instance
672 672 """
673 673 from dulwich.server import update_server_info
674 674 try:
675 675 update_server_info(self._repo)
676 676 except OSError as e:
677 677 if e.errno not in [errno.ENOENT, errno.EROFS]:
678 678 raise
679 679 # Workaround for dulwich crashing on for example its own dulwich/tests/data/repos/simple_merge.git/info/refs.lock
680 680 log.error('Ignoring %s running update-server-info: %s', type(e).__name__, e)
681 681
682 682 @LazyProperty
683 683 def workdir(self):
684 684 """
685 685 Returns ``Workdir`` instance for this repository.
686 686 """
687 687 return GitWorkdir(self)
688 688
689 689 def get_config_value(self, section, name, config_file=None):
690 690 """
691 691 Returns configuration value for a given [``section``] and ``name``.
692 692
693 693 :param section: Section we want to retrieve value from
694 694 :param name: Name of configuration we want to retrieve
695 695 :param config_file: A path to file which should be used to retrieve
696 696 configuration from (might also be a list of file paths)
697 697 """
698 698 if config_file is None:
699 699 config_file = []
700 700 elif isinstance(config_file, str):
701 701 config_file = [config_file]
702 702
703 703 def gen_configs():
704 704 for path in config_file + self._config_files:
705 705 try:
706 706 yield ConfigFile.from_path(path)
707 707 except (IOError, OSError, ValueError):
708 708 continue
709 709
710 710 for config in gen_configs():
711 711 try:
712 712 value = config.get(section, name)
713 713 except KeyError:
714 714 continue
715 715 return None if value is None else safe_str(value)
716 716 return None
717 717
718 718 def get_user_name(self, config_file=None):
719 719 """
720 720 Returns user's name from global configuration file.
721 721
722 722 :param config_file: A path to file which should be used to retrieve
723 723 configuration from (might also be a list of file paths)
724 724 """
725 725 return self.get_config_value('user', 'name', config_file)
726 726
727 727 def get_user_email(self, config_file=None):
728 728 """
729 729 Returns user's email from global configuration file.
730 730
731 731 :param config_file: A path to file which should be used to retrieve
732 732 configuration from (might also be a list of file paths)
733 733 """
734 734 return self.get_config_value('user', 'email', config_file)
@@ -1,395 +1,395 b''
1 1 import os
2 2 import posixpath
3 3
4 4 import mercurial.archival
5 5 import mercurial.node
6 6 import mercurial.obsutil
7 7
8 8 from kallithea.lib.vcs.backends.base import BaseChangeset
9 9 from kallithea.lib.vcs.conf import settings
10 10 from kallithea.lib.vcs.exceptions import ChangesetDoesNotExistError, ChangesetError, ImproperArchiveTypeError, NodeDoesNotExistError, VCSError
11 from kallithea.lib.vcs.nodes import (
12 AddedFileNodesGenerator, ChangedFileNodesGenerator, DirNode, FileNode, NodeKind, RemovedFileNodesGenerator, RootNode, SubModuleNode)
11 from kallithea.lib.vcs.nodes import (AddedFileNodesGenerator, ChangedFileNodesGenerator, DirNode, FileNode, NodeKind, RemovedFileNodesGenerator, RootNode,
12 SubModuleNode)
13 13 from kallithea.lib.vcs.utils import ascii_bytes, ascii_str, date_fromtimestamp, safe_bytes, safe_str
14 14 from kallithea.lib.vcs.utils.lazy import LazyProperty
15 15 from kallithea.lib.vcs.utils.paths import get_dirs_for_path
16 16
17 17
18 18 class MercurialChangeset(BaseChangeset):
19 19 """
20 20 Represents state of the repository at a revision.
21 21 """
22 22
23 23 def __init__(self, repository, revision):
24 24 self.repository = repository
25 25 assert isinstance(revision, str), repr(revision)
26 26 self._ctx = repository._repo[ascii_bytes(revision)]
27 27 self.raw_id = ascii_str(self._ctx.hex())
28 28 self.revision = self._ctx._rev
29 29 self.nodes = {}
30 30
31 31 @LazyProperty
32 32 def tags(self):
33 33 return [safe_str(tag) for tag in self._ctx.tags()]
34 34
35 35 @LazyProperty
36 36 def branch(self):
37 37 return safe_str(self._ctx.branch())
38 38
39 39 @LazyProperty
40 40 def branches(self):
41 41 return [safe_str(self._ctx.branch())]
42 42
43 43 @LazyProperty
44 44 def closesbranch(self):
45 45 return self._ctx.closesbranch()
46 46
47 47 @LazyProperty
48 48 def obsolete(self):
49 49 return self._ctx.obsolete()
50 50
51 51 @LazyProperty
52 52 def bumped(self):
53 53 return self._ctx.phasedivergent()
54 54
55 55 @LazyProperty
56 56 def divergent(self):
57 57 return self._ctx.contentdivergent()
58 58
59 59 @LazyProperty
60 60 def extinct(self):
61 61 return self._ctx.extinct()
62 62
63 63 @LazyProperty
64 64 def unstable(self):
65 65 return self._ctx.orphan()
66 66
67 67 @LazyProperty
68 68 def phase(self):
69 69 if(self._ctx.phase() == 1):
70 70 return 'Draft'
71 71 elif(self._ctx.phase() == 2):
72 72 return 'Secret'
73 73 else:
74 74 return ''
75 75
76 76 @LazyProperty
77 77 def successors(self):
78 78 successors = mercurial.obsutil.successorssets(self._ctx._repo, self._ctx.node(), closest=True)
79 79 if successors:
80 80 # flatten the list here handles both divergent (len > 1)
81 81 # and the usual case (len = 1)
82 82 successors = [mercurial.node.hex(n)[:12] for sub in successors for n in sub if n != self._ctx.node()]
83 83
84 84 return successors
85 85
86 86 @LazyProperty
87 87 def predecessors(self):
88 88 return [mercurial.node.hex(n)[:12] for n in mercurial.obsutil.closestpredecessors(self._ctx._repo, self._ctx.node())]
89 89
90 90 @LazyProperty
91 91 def bookmarks(self):
92 92 return [safe_str(bookmark) for bookmark in self._ctx.bookmarks()]
93 93
94 94 @LazyProperty
95 95 def message(self):
96 96 return safe_str(self._ctx.description())
97 97
98 98 @LazyProperty
99 99 def committer(self):
100 100 return safe_str(self.author)
101 101
102 102 @LazyProperty
103 103 def author(self):
104 104 return safe_str(self._ctx.user())
105 105
106 106 @LazyProperty
107 107 def date(self):
108 108 return date_fromtimestamp(*self._ctx.date())
109 109
110 110 @LazyProperty
111 111 def _timestamp(self):
112 112 return self._ctx.date()[0]
113 113
114 114 @LazyProperty
115 115 def status(self):
116 116 """
117 117 Returns modified, added, removed, deleted files for current changeset
118 118 """
119 119 return self.repository._repo.status(self._ctx.p1().node(),
120 120 self._ctx.node())
121 121
122 122 @LazyProperty
123 123 def _file_paths(self):
124 124 return list(safe_str(f) for f in self._ctx)
125 125
126 126 @LazyProperty
127 127 def _dir_paths(self):
128 128 p = list(set(get_dirs_for_path(*self._file_paths)))
129 129 p.insert(0, '')
130 130 return p
131 131
132 132 @LazyProperty
133 133 def _paths(self):
134 134 return self._dir_paths + self._file_paths
135 135
136 136 @LazyProperty
137 137 def short_id(self):
138 138 return self.raw_id[:12]
139 139
140 140 @LazyProperty
141 141 def parents(self):
142 142 """
143 143 Returns list of parents changesets.
144 144 """
145 145 return [self.repository.get_changeset(parent.rev())
146 146 for parent in self._ctx.parents() if parent.rev() >= 0]
147 147
148 148 @LazyProperty
149 149 def children(self):
150 150 """
151 151 Returns list of children changesets.
152 152 """
153 153 return [self.repository.get_changeset(child.rev())
154 154 for child in self._ctx.children() if child.rev() >= 0]
155 155
156 156 def next(self, branch=None):
157 157 if branch and self.branch != branch:
158 158 raise VCSError('Branch option used on changeset not belonging '
159 159 'to that branch')
160 160
161 161 cs = self
162 162 while True:
163 163 try:
164 164 next_ = cs.repository.revisions.index(cs.raw_id) + 1
165 165 next_rev = cs.repository.revisions[next_]
166 166 except IndexError:
167 167 raise ChangesetDoesNotExistError
168 168 cs = cs.repository.get_changeset(next_rev)
169 169
170 170 if not branch or branch == cs.branch:
171 171 return cs
172 172
173 173 def prev(self, branch=None):
174 174 if branch and self.branch != branch:
175 175 raise VCSError('Branch option used on changeset not belonging '
176 176 'to that branch')
177 177
178 178 cs = self
179 179 while True:
180 180 try:
181 181 prev_ = cs.repository.revisions.index(cs.raw_id) - 1
182 182 if prev_ < 0:
183 183 raise IndexError
184 184 prev_rev = cs.repository.revisions[prev_]
185 185 except IndexError:
186 186 raise ChangesetDoesNotExistError
187 187 cs = cs.repository.get_changeset(prev_rev)
188 188
189 189 if not branch or branch == cs.branch:
190 190 return cs
191 191
192 192 def diff(self):
193 193 # Only used to feed diffstat
194 194 return b''.join(self._ctx.diff())
195 195
196 196 def _get_kind(self, path):
197 197 path = path.rstrip('/')
198 198 if path in self._file_paths:
199 199 return NodeKind.FILE
200 200 elif path in self._dir_paths:
201 201 return NodeKind.DIR
202 202 else:
203 203 raise ChangesetError("Node does not exist at the given path '%s'"
204 204 % (path))
205 205
206 206 def _get_filectx(self, path):
207 207 path = path.rstrip('/')
208 208 if self._get_kind(path) != NodeKind.FILE:
209 209 raise ChangesetError("File does not exist for revision %s at "
210 210 " '%s'" % (self.raw_id, path))
211 211 return self._ctx.filectx(safe_bytes(path))
212 212
213 213 def _extract_submodules(self):
214 214 """
215 215 returns a dictionary with submodule information from substate file
216 216 of hg repository
217 217 """
218 218 return self._ctx.substate
219 219
220 220 def get_file_mode(self, path):
221 221 """
222 222 Returns stat mode of the file at the given ``path``.
223 223 """
224 224 fctx = self._get_filectx(path)
225 225 if b'x' in fctx.flags():
226 226 return 0o100755
227 227 else:
228 228 return 0o100644
229 229
230 230 def get_file_content(self, path):
231 231 """
232 232 Returns content of the file at given ``path``.
233 233 """
234 234 fctx = self._get_filectx(path)
235 235 return fctx.data()
236 236
237 237 def get_file_size(self, path):
238 238 """
239 239 Returns size of the file at given ``path``.
240 240 """
241 241 fctx = self._get_filectx(path)
242 242 return fctx.size()
243 243
244 244 def get_file_changeset(self, path):
245 245 """
246 246 Returns last commit of the file at the given ``path``.
247 247 """
248 248 return self.get_file_history(path, limit=1)[0]
249 249
250 250 def get_file_history(self, path, limit=None):
251 251 """
252 252 Returns history of file as reversed list of ``Changeset`` objects for
253 253 which file at given ``path`` has been modified.
254 254 """
255 255 fctx = self._get_filectx(path)
256 256 hist = []
257 257 cnt = 0
258 258 for cs in reversed([x for x in fctx.filelog()]):
259 259 cnt += 1
260 260 hist.append(mercurial.node.hex(fctx.filectx(cs).node()))
261 261 if limit is not None and cnt == limit:
262 262 break
263 263
264 264 return [self.repository.get_changeset(node) for node in hist]
265 265
266 266 def get_file_annotate(self, path):
267 267 """
268 268 Returns a generator of four element tuples with
269 269 lineno, sha, changeset lazy loader and line
270 270 """
271 271 annotations = self._get_filectx(path).annotate()
272 272 annotation_lines = [(annotateline.fctx, annotateline.text) for annotateline in annotations]
273 273 for i, (fctx, line) in enumerate(annotation_lines):
274 274 sha = ascii_str(fctx.hex())
275 275 yield (i + 1, sha, lambda sha=sha: self.repository.get_changeset(sha), line)
276 276
277 277 def fill_archive(self, stream=None, kind='tgz', prefix=None,
278 278 subrepos=False):
279 279 """
280 280 Fills up given stream.
281 281
282 282 :param stream: file like object.
283 283 :param kind: one of following: ``zip``, ``tgz`` or ``tbz2``.
284 284 Default: ``tgz``.
285 285 :param prefix: name of root directory in archive.
286 286 Default is repository name and changeset's raw_id joined with dash
287 287 (``repo-tip.<KIND>``).
288 288 :param subrepos: include subrepos in this archive.
289 289
290 290 :raise ImproperArchiveTypeError: If given kind is wrong.
291 291 :raise VcsError: If given stream is None
292 292 """
293 293 allowed_kinds = settings.ARCHIVE_SPECS
294 294 if kind not in allowed_kinds:
295 295 raise ImproperArchiveTypeError('Archive kind not supported use one'
296 296 'of %s' % ' '.join(allowed_kinds))
297 297
298 298 if stream is None:
299 299 raise VCSError('You need to pass in a valid stream for filling'
300 300 ' with archival data')
301 301
302 302 if prefix is None:
303 303 prefix = '%s-%s' % (self.repository.name, self.short_id)
304 304 elif prefix.startswith('/'):
305 305 raise VCSError("Prefix cannot start with leading slash")
306 306 elif prefix.strip() == '':
307 307 raise VCSError("Prefix cannot be empty")
308 308
309 309 mercurial.archival.archive(self.repository._repo, stream, ascii_bytes(self.raw_id),
310 310 safe_bytes(kind), prefix=safe_bytes(prefix), subrepos=subrepos)
311 311
312 312 def get_nodes(self, path):
313 313 """
314 314 Returns combined ``DirNode`` and ``FileNode`` objects list representing
315 315 state of changeset at the given ``path``. If node at the given ``path``
316 316 is not instance of ``DirNode``, ChangesetError would be raised.
317 317 """
318 318
319 319 if self._get_kind(path) != NodeKind.DIR:
320 320 raise ChangesetError("Directory does not exist for revision %s at "
321 321 " '%s'" % (self.revision, path))
322 322 path = path.rstrip('/')
323 323 filenodes = [FileNode(f, changeset=self) for f in self._file_paths
324 324 if os.path.dirname(f) == path]
325 325 dirs = path == '' and '' or [d for d in self._dir_paths
326 326 if d and posixpath.dirname(d) == path]
327 327 dirnodes = [DirNode(d, changeset=self) for d in dirs
328 328 if os.path.dirname(d) == path]
329 329
330 330 als = self.repository.alias
331 331 for k, vals in self._extract_submodules().items():
332 332 #vals = url,rev,type
333 333 loc = vals[0]
334 334 cs = vals[1]
335 335 dirnodes.append(SubModuleNode(k, url=loc, changeset=cs,
336 336 alias=als))
337 337 nodes = dirnodes + filenodes
338 338 for node in nodes:
339 339 self.nodes[node.path] = node
340 340 nodes.sort()
341 341 return nodes
342 342
343 343 def get_node(self, path):
344 344 """
345 345 Returns ``Node`` object from the given ``path``. If there is no node at
346 346 the given ``path``, ``ChangesetError`` would be raised.
347 347 """
348 348 path = path.rstrip('/')
349 349 if path not in self.nodes:
350 350 if path in self._file_paths:
351 351 node = FileNode(path, changeset=self)
352 352 elif path in self._dir_paths or path in self._dir_paths:
353 353 if path == '':
354 354 node = RootNode(changeset=self)
355 355 else:
356 356 node = DirNode(path, changeset=self)
357 357 else:
358 358 raise NodeDoesNotExistError("There is no file nor directory "
359 359 "at the given path: '%s' at revision %s"
360 360 % (path, self.short_id))
361 361 # cache node
362 362 self.nodes[path] = node
363 363 return self.nodes[path]
364 364
365 365 @LazyProperty
366 366 def affected_files(self):
367 367 """
368 368 Gets a fast accessible file changes for given changeset
369 369 """
370 370 return self._ctx.files()
371 371
372 372 @property
373 373 def added(self):
374 374 """
375 375 Returns list of added ``FileNode`` objects.
376 376 """
377 377 return AddedFileNodesGenerator([safe_str(n) for n in self.status.added], self)
378 378
379 379 @property
380 380 def changed(self):
381 381 """
382 382 Returns list of modified ``FileNode`` objects.
383 383 """
384 384 return ChangedFileNodesGenerator([safe_str(n) for n in self.status.modified], self)
385 385
386 386 @property
387 387 def removed(self):
388 388 """
389 389 Returns list of removed ``FileNode`` objects.
390 390 """
391 391 return RemovedFileNodesGenerator([safe_str(n) for n in self.status.removed], self)
392 392
393 393 @LazyProperty
394 394 def extra(self):
395 395 return self._ctx.extra()
@@ -1,618 +1,618 b''
1 1 # -*- coding: utf-8 -*-
2 2 """
3 3 vcs.backends.hg.repository
4 4 ~~~~~~~~~~~~~~~~~~~~~~~~~~
5 5
6 6 Mercurial repository implementation.
7 7
8 8 :created_on: Apr 8, 2010
9 9 :copyright: (c) 2010-2011 by Marcin Kuzminski, Lukasz Balcerzak.
10 10 """
11 11
12 12 import datetime
13 13 import logging
14 14 import os
15 15 import time
16 16 import urllib.error
17 17 import urllib.parse
18 18 import urllib.request
19 19 from collections import OrderedDict
20 20
21 21 import mercurial.commands
22 22 import mercurial.error
23 23 import mercurial.exchange
24 24 import mercurial.hg
25 25 import mercurial.hgweb
26 26 import mercurial.httppeer
27 27 import mercurial.localrepo
28 28 import mercurial.match
29 29 import mercurial.mdiff
30 30 import mercurial.node
31 31 import mercurial.patch
32 32 import mercurial.scmutil
33 33 import mercurial.sshpeer
34 34 import mercurial.tags
35 35 import mercurial.ui
36 36 import mercurial.url
37 37 import mercurial.util
38 38
39 39 from kallithea.lib.vcs.backends.base import BaseRepository, CollectionGenerator
40 from kallithea.lib.vcs.exceptions import (
41 BranchDoesNotExistError, ChangesetDoesNotExistError, EmptyRepositoryError, RepositoryError, TagAlreadyExistError, TagDoesNotExistError, VCSError)
40 from kallithea.lib.vcs.exceptions import (BranchDoesNotExistError, ChangesetDoesNotExistError, EmptyRepositoryError, RepositoryError, TagAlreadyExistError,
41 TagDoesNotExistError, VCSError)
42 42 from kallithea.lib.vcs.utils import ascii_str, author_email, author_name, date_fromtimestamp, makedate, safe_bytes, safe_str
43 43 from kallithea.lib.vcs.utils.lazy import LazyProperty
44 44 from kallithea.lib.vcs.utils.paths import abspath
45 45
46 46 from .changeset import MercurialChangeset
47 47 from .inmemory import MercurialInMemoryChangeset
48 48 from .workdir import MercurialWorkdir
49 49
50 50
51 51 log = logging.getLogger(__name__)
52 52
53 53
54 54 class MercurialRepository(BaseRepository):
55 55 """
56 56 Mercurial repository backend
57 57 """
58 58 DEFAULT_BRANCH_NAME = 'default'
59 59 scm = 'hg'
60 60
61 61 def __init__(self, repo_path, create=False, baseui=None, src_url=None,
62 62 update_after_clone=False):
63 63 """
64 64 Raises RepositoryError if repository could not be find at the given
65 65 ``repo_path``.
66 66
67 67 :param repo_path: local path of the repository
68 68 :param create=False: if set to True, would try to create repository if
69 69 it does not exist rather than raising exception
70 70 :param baseui=None: user data
71 71 :param src_url=None: would try to clone repository from given location
72 72 :param update_after_clone=False: sets update of working copy after
73 73 making a clone
74 74 """
75 75
76 76 if not isinstance(repo_path, str):
77 77 raise VCSError('Mercurial backend requires repository path to '
78 78 'be instance of <str> got %s instead' %
79 79 type(repo_path))
80 80 self.path = abspath(repo_path)
81 81 self.baseui = baseui or mercurial.ui.ui()
82 82 # We've set path and ui, now we can set _repo itself
83 83 self._repo = self._get_repo(create, src_url, update_after_clone)
84 84
85 85 @property
86 86 def _empty(self):
87 87 """
88 88 Checks if repository is empty ie. without any changesets
89 89 """
90 90 # TODO: Following raises errors when using InMemoryChangeset...
91 91 # return len(self._repo.changelog) == 0
92 92 return len(self.revisions) == 0
93 93
94 94 @LazyProperty
95 95 def revisions(self):
96 96 """
97 97 Returns list of revisions' ids, in ascending order. Being lazy
98 98 attribute allows external tools to inject shas from cache.
99 99 """
100 100 return self._get_all_revisions()
101 101
102 102 @LazyProperty
103 103 def name(self):
104 104 return os.path.basename(self.path)
105 105
106 106 @LazyProperty
107 107 def branches(self):
108 108 return self._get_branches()
109 109
110 110 @LazyProperty
111 111 def closed_branches(self):
112 112 return self._get_branches(normal=False, closed=True)
113 113
114 114 @LazyProperty
115 115 def allbranches(self):
116 116 """
117 117 List all branches, including closed branches.
118 118 """
119 119 return self._get_branches(closed=True)
120 120
121 121 def _get_branches(self, normal=True, closed=False):
122 122 """
123 123 Gets branches for this repository
124 124 Returns only not closed branches by default
125 125
126 126 :param closed: return also closed branches for mercurial
127 127 :param normal: return also normal branches
128 128 """
129 129
130 130 if self._empty:
131 131 return {}
132 132
133 133 bt = OrderedDict()
134 134 for bn, _heads, node, isclosed in sorted(self._repo.branchmap().iterbranches()):
135 135 if isclosed:
136 136 if closed:
137 137 bt[safe_str(bn)] = ascii_str(mercurial.node.hex(node))
138 138 else:
139 139 if normal:
140 140 bt[safe_str(bn)] = ascii_str(mercurial.node.hex(node))
141 141 return bt
142 142
143 143 @LazyProperty
144 144 def tags(self):
145 145 """
146 146 Gets tags for this repository
147 147 """
148 148 return self._get_tags()
149 149
150 150 def _get_tags(self):
151 151 if self._empty:
152 152 return {}
153 153
154 154 return OrderedDict(sorted(
155 155 ((safe_str(n), ascii_str(mercurial.node.hex(h))) for n, h in self._repo.tags().items()),
156 156 reverse=True,
157 157 key=lambda x: x[0], # sort by name
158 158 ))
159 159
160 160 def tag(self, name, user, revision=None, message=None, date=None,
161 161 **kwargs):
162 162 """
163 163 Creates and returns a tag for the given ``revision``.
164 164
165 165 :param name: name for new tag
166 166 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
167 167 :param revision: changeset id for which new tag would be created
168 168 :param message: message of the tag's commit
169 169 :param date: date of tag's commit
170 170
171 171 :raises TagAlreadyExistError: if tag with same name already exists
172 172 """
173 173 if name in self.tags:
174 174 raise TagAlreadyExistError("Tag %s already exists" % name)
175 175 changeset = self.get_changeset(revision)
176 176 local = kwargs.setdefault('local', False)
177 177
178 178 if message is None:
179 179 message = "Added tag %s for changeset %s" % (name,
180 180 changeset.short_id)
181 181
182 182 if date is None:
183 183 date = safe_bytes(datetime.datetime.now().strftime('%a, %d %b %Y %H:%M:%S'))
184 184
185 185 try:
186 186 mercurial.tags.tag(self._repo, safe_bytes(name), changeset._ctx.node(), safe_bytes(message), local, safe_bytes(user), date)
187 187 except mercurial.error.Abort as e:
188 188 raise RepositoryError(e.args[0])
189 189
190 190 # Reinitialize tags
191 191 self.tags = self._get_tags()
192 192 tag_id = self.tags[name]
193 193
194 194 return self.get_changeset(revision=tag_id)
195 195
196 196 def remove_tag(self, name, user, message=None, date=None):
197 197 """
198 198 Removes tag with the given ``name``.
199 199
200 200 :param name: name of the tag to be removed
201 201 :param user: full username, i.e.: "Joe Doe <joe.doe@example.com>"
202 202 :param message: message of the tag's removal commit
203 203 :param date: date of tag's removal commit
204 204
205 205 :raises TagDoesNotExistError: if tag with given name does not exists
206 206 """
207 207 if name not in self.tags:
208 208 raise TagDoesNotExistError("Tag %s does not exist" % name)
209 209 if message is None:
210 210 message = "Removed tag %s" % name
211 211 if date is None:
212 212 date = safe_bytes(datetime.datetime.now().strftime('%a, %d %b %Y %H:%M:%S'))
213 213 local = False
214 214
215 215 try:
216 216 mercurial.tags.tag(self._repo, safe_bytes(name), mercurial.commands.nullid, safe_bytes(message), local, safe_bytes(user), date)
217 217 self.tags = self._get_tags()
218 218 except mercurial.error.Abort as e:
219 219 raise RepositoryError(e.args[0])
220 220
221 221 @LazyProperty
222 222 def bookmarks(self):
223 223 """
224 224 Gets bookmarks for this repository
225 225 """
226 226 return self._get_bookmarks()
227 227
228 228 def _get_bookmarks(self):
229 229 if self._empty:
230 230 return {}
231 231
232 232 return OrderedDict(sorted(
233 233 ((safe_str(n), ascii_str(h)) for n, h in self._repo._bookmarks.items()),
234 234 reverse=True,
235 235 key=lambda x: x[0], # sort by name
236 236 ))
237 237
238 238 def _get_all_revisions(self):
239 239 return [ascii_str(self._repo[x].hex()) for x in self._repo.filtered(b'visible').changelog.revs()]
240 240
241 241 def get_diff(self, rev1, rev2, path='', ignore_whitespace=False,
242 242 context=3):
243 243 """
244 244 Returns (git like) *diff*, as plain text. Shows changes introduced by
245 245 ``rev2`` since ``rev1``.
246 246
247 247 :param rev1: Entry point from which diff is shown. Can be
248 248 ``self.EMPTY_CHANGESET`` - in this case, patch showing all
249 249 the changes since empty state of the repository until ``rev2``
250 250 :param rev2: Until which revision changes should be shown.
251 251 :param ignore_whitespace: If set to ``True``, would not show whitespace
252 252 changes. Defaults to ``False``.
253 253 :param context: How many lines before/after changed lines should be
254 254 shown. Defaults to ``3``. If negative value is passed-in, it will be
255 255 set to ``0`` instead.
256 256 """
257 257
258 258 # Negative context values make no sense, and will result in
259 259 # errors. Ensure this does not happen.
260 260 if context < 0:
261 261 context = 0
262 262
263 263 if hasattr(rev1, 'raw_id'):
264 264 rev1 = getattr(rev1, 'raw_id')
265 265
266 266 if hasattr(rev2, 'raw_id'):
267 267 rev2 = getattr(rev2, 'raw_id')
268 268
269 269 # Check if given revisions are present at repository (may raise
270 270 # ChangesetDoesNotExistError)
271 271 if rev1 != self.EMPTY_CHANGESET:
272 272 self.get_changeset(rev1)
273 273 self.get_changeset(rev2)
274 274 if path:
275 275 file_filter = mercurial.match.exact(path)
276 276 else:
277 277 file_filter = None
278 278
279 279 return b''.join(mercurial.patch.diff(self._repo, rev1, rev2, match=file_filter,
280 280 opts=mercurial.mdiff.diffopts(git=True,
281 281 showfunc=True,
282 282 ignorews=ignore_whitespace,
283 283 context=context)))
284 284
285 285 @classmethod
286 286 def _check_url(cls, url, repoui=None):
287 287 """
288 288 Function will check given url and try to verify if it's a valid
289 289 link. Sometimes it may happened that mercurial will issue basic
290 290 auth request that can cause whole API to hang when used from python
291 291 or other external calls.
292 292
293 293 On failures it'll raise urllib2.HTTPError, exception is also thrown
294 294 when the return code is non 200
295 295 """
296 296 # check first if it's not an local url
297 297 url = safe_bytes(url)
298 298 if os.path.isdir(url) or url.startswith(b'file:'):
299 299 return True
300 300
301 301 if url.startswith(b'ssh:'):
302 302 # in case of invalid uri or authentication issues, sshpeer will
303 303 # throw an exception.
304 304 mercurial.sshpeer.instance(repoui or mercurial.ui.ui(), url, False).lookup(b'tip')
305 305 return True
306 306
307 307 url_prefix = None
308 308 if b'+' in url[:url.find(b'://')]:
309 309 url_prefix, url = url.split(b'+', 1)
310 310
311 311 handlers = []
312 312 url_obj = mercurial.util.url(url)
313 313 test_uri, authinfo = url_obj.authinfo()
314 314 url_obj.passwd = b'*****'
315 315 cleaned_uri = str(url_obj)
316 316
317 317 if authinfo:
318 318 # create a password manager
319 319 passmgr = urllib.request.HTTPPasswordMgrWithDefaultRealm()
320 320 passmgr.add_password(*authinfo)
321 321
322 322 handlers.extend((mercurial.url.httpbasicauthhandler(passmgr),
323 323 mercurial.url.httpdigestauthhandler(passmgr)))
324 324
325 325 o = urllib.request.build_opener(*handlers)
326 326 o.addheaders = [('Content-Type', 'application/mercurial-0.1'),
327 327 ('Accept', 'application/mercurial-0.1')]
328 328
329 329 req = urllib.request.Request(
330 330 "%s?%s" % (
331 331 test_uri,
332 332 urllib.parse.urlencode({
333 333 'cmd': 'between',
334 334 'pairs': "%s-%s" % ('0' * 40, '0' * 40),
335 335 })
336 336 ))
337 337
338 338 try:
339 339 resp = o.open(req)
340 340 if resp.code != 200:
341 341 raise Exception('Return Code is not 200')
342 342 except Exception as e:
343 343 # means it cannot be cloned
344 344 raise urllib.error.URLError("[%s] org_exc: %s" % (cleaned_uri, e))
345 345
346 346 if not url_prefix: # skip svn+http://... (and git+... too)
347 347 # now check if it's a proper hg repo
348 348 try:
349 349 mercurial.httppeer.instance(repoui or mercurial.ui.ui(), url, False).lookup(b'tip')
350 350 except Exception as e:
351 351 raise urllib.error.URLError(
352 352 "url [%s] does not look like an hg repo org_exc: %s"
353 353 % (cleaned_uri, e))
354 354
355 355 return True
356 356
357 357 def _get_repo(self, create, src_url=None, update_after_clone=False):
358 358 """
359 359 Function will check for mercurial repository in given path and return
360 360 a localrepo object. If there is no repository in that path it will
361 361 raise an exception unless ``create`` parameter is set to True - in
362 362 that case repository would be created and returned.
363 363 If ``src_url`` is given, would try to clone repository from the
364 364 location at given clone_point. Additionally it'll make update to
365 365 working copy accordingly to ``update_after_clone`` flag
366 366 """
367 367 try:
368 368 if src_url:
369 369 url = safe_bytes(self._get_url(src_url))
370 370 opts = {}
371 371 if not update_after_clone:
372 372 opts.update({'noupdate': True})
373 373 MercurialRepository._check_url(url, self.baseui)
374 374 mercurial.commands.clone(self.baseui, url, safe_bytes(self.path), **opts)
375 375
376 376 # Don't try to create if we've already cloned repo
377 377 create = False
378 378 return mercurial.localrepo.instance(self.baseui, safe_bytes(self.path), create=create)
379 379 except (mercurial.error.Abort, mercurial.error.RepoError) as err:
380 380 if create:
381 381 msg = "Cannot create repository at %s. Original error was %s" \
382 382 % (self.name, err)
383 383 else:
384 384 msg = "Not valid repository at %s. Original error was %s" \
385 385 % (self.name, err)
386 386 raise RepositoryError(msg)
387 387
388 388 @LazyProperty
389 389 def in_memory_changeset(self):
390 390 return MercurialInMemoryChangeset(self)
391 391
392 392 @LazyProperty
393 393 def description(self):
394 394 _desc = self._repo.ui.config(b'web', b'description', None, untrusted=True)
395 395 return safe_str(_desc or b'unknown')
396 396
397 397 @LazyProperty
398 398 def contact(self):
399 399 return safe_str(mercurial.hgweb.common.get_contact(self._repo.ui.config)
400 400 or b'Unknown')
401 401
402 402 @LazyProperty
403 403 def last_change(self):
404 404 """
405 405 Returns last change made on this repository as datetime object
406 406 """
407 407 return date_fromtimestamp(self._get_mtime(), makedate()[1])
408 408
409 409 def _get_mtime(self):
410 410 try:
411 411 return time.mktime(self.get_changeset().date.timetuple())
412 412 except RepositoryError:
413 413 # fallback to filesystem
414 414 cl_path = os.path.join(self.path, '.hg', "00changelog.i")
415 415 st_path = os.path.join(self.path, '.hg', "store")
416 416 if os.path.exists(cl_path):
417 417 return os.stat(cl_path).st_mtime
418 418 else:
419 419 return os.stat(st_path).st_mtime
420 420
421 421 def _get_revision(self, revision):
422 422 """
423 423 Given any revision identifier, returns a 40 char string with revision hash.
424 424
425 425 :param revision: str or int or None
426 426 """
427 427 if self._empty:
428 428 raise EmptyRepositoryError("There are no changesets yet")
429 429
430 430 if revision in [-1, None]:
431 431 revision = b'tip'
432 432 elif isinstance(revision, str):
433 433 revision = safe_bytes(revision)
434 434
435 435 try:
436 436 if isinstance(revision, int):
437 437 return ascii_str(self._repo[revision].hex())
438 438 return ascii_str(mercurial.scmutil.revsymbol(self._repo, revision).hex())
439 439 except (IndexError, ValueError, mercurial.error.RepoLookupError, TypeError):
440 440 msg = "Revision %r does not exist for %s" % (safe_str(revision), self.name)
441 441 raise ChangesetDoesNotExistError(msg)
442 442 except (LookupError, ):
443 443 msg = "Ambiguous identifier `%s` for %s" % (safe_str(revision), self.name)
444 444 raise ChangesetDoesNotExistError(msg)
445 445
446 446 def get_ref_revision(self, ref_type, ref_name):
447 447 """
448 448 Returns revision number for the given reference.
449 449 """
450 450 if ref_type == 'rev' and not ref_name.strip('0'):
451 451 return self.EMPTY_CHANGESET
452 452 # lookup up the exact node id
453 453 _revset_predicates = {
454 454 'branch': 'branch',
455 455 'book': 'bookmark',
456 456 'tag': 'tag',
457 457 'rev': 'id',
458 458 }
459 459 # avoid expensive branch(x) iteration over whole repo
460 460 rev_spec = "%%s & %s(%%s)" % _revset_predicates[ref_type]
461 461 try:
462 462 revs = self._repo.revs(rev_spec, ref_name, ref_name)
463 463 except LookupError:
464 464 msg = "Ambiguous identifier %s:%s for %s" % (ref_type, ref_name, self.name)
465 465 raise ChangesetDoesNotExistError(msg)
466 466 except mercurial.error.RepoLookupError:
467 467 msg = "Revision %s:%s does not exist for %s" % (ref_type, ref_name, self.name)
468 468 raise ChangesetDoesNotExistError(msg)
469 469 if revs:
470 470 revision = revs.last()
471 471 else:
472 472 # TODO: just report 'not found'?
473 473 revision = ref_name
474 474
475 475 return self._get_revision(revision)
476 476
477 477 def _get_archives(self, archive_name='tip'):
478 478 allowed = self.baseui.configlist(b"web", b"allow_archive",
479 479 untrusted=True)
480 480 for name, ext in [(b'zip', '.zip'), (b'gz', '.tar.gz'), (b'bz2', '.tar.bz2')]:
481 481 if name in allowed or self._repo.ui.configbool(b"web",
482 482 b"allow" + name,
483 483 untrusted=True):
484 484 yield {"type": safe_str(name), "extension": ext, "node": archive_name}
485 485
486 486 def _get_url(self, url):
487 487 """
488 488 Returns normalized url. If schema is not given, fall back to
489 489 filesystem (``file:///``) schema.
490 490 """
491 491 if url != 'default' and '://' not in url:
492 492 url = "file:" + urllib.request.pathname2url(url)
493 493 return url
494 494
495 495 def get_changeset(self, revision=None):
496 496 """
497 497 Returns ``MercurialChangeset`` object representing repository's
498 498 changeset at the given ``revision``.
499 499 """
500 500 return MercurialChangeset(repository=self, revision=self._get_revision(revision))
501 501
502 502 def get_changesets(self, start=None, end=None, start_date=None,
503 503 end_date=None, branch_name=None, reverse=False, max_revisions=None):
504 504 """
505 505 Returns iterator of ``MercurialChangeset`` objects from start to end
506 506 (both are inclusive)
507 507
508 508 :param start: None, str, int or mercurial lookup format
509 509 :param end: None, str, int or mercurial lookup format
510 510 :param start_date:
511 511 :param end_date:
512 512 :param branch_name:
513 513 :param reversed: return changesets in reversed order
514 514 """
515 515 start_raw_id = self._get_revision(start)
516 516 start_pos = None if start is None else self.revisions.index(start_raw_id)
517 517 end_raw_id = self._get_revision(end)
518 518 end_pos = None if end is None else self.revisions.index(end_raw_id)
519 519
520 520 if start_pos is not None and end_pos is not None and start_pos > end_pos:
521 521 raise RepositoryError("Start revision '%s' cannot be "
522 522 "after end revision '%s'" % (start, end))
523 523
524 524 if branch_name and branch_name not in self.allbranches:
525 525 msg = "Branch %r not found in %s" % (branch_name, self.name)
526 526 raise BranchDoesNotExistError(msg)
527 527 if end_pos is not None:
528 528 end_pos += 1
529 529 # filter branches
530 530 filter_ = []
531 531 if branch_name:
532 532 filter_.append(b'branch("%s")' % safe_bytes(branch_name))
533 533 if start_date:
534 534 filter_.append(b'date(">%s")' % safe_bytes(str(start_date)))
535 535 if end_date:
536 536 filter_.append(b'date("<%s")' % safe_bytes(str(end_date)))
537 537 if filter_ or max_revisions:
538 538 if filter_:
539 539 revspec = b' and '.join(filter_)
540 540 else:
541 541 revspec = b'all()'
542 542 if max_revisions:
543 543 revspec = b'limit(%s, %d)' % (revspec, max_revisions)
544 544 revisions = mercurial.scmutil.revrange(self._repo, [revspec])
545 545 else:
546 546 revisions = self.revisions
547 547
548 548 # this is very much a hack to turn this into a list; a better solution
549 549 # would be to get rid of this function entirely and use revsets
550 550 revs = list(revisions)[start_pos:end_pos]
551 551 if reverse:
552 552 revs.reverse()
553 553
554 554 return CollectionGenerator(self, revs)
555 555
556 556 def pull(self, url):
557 557 """
558 558 Tries to pull changes from external location.
559 559 """
560 560 other = mercurial.hg.peer(self._repo, {}, safe_bytes(self._get_url(url)))
561 561 try:
562 562 mercurial.exchange.pull(self._repo, other, heads=None, force=None)
563 563 except mercurial.error.Abort as err:
564 564 # Propagate error but with vcs's type
565 565 raise RepositoryError(str(err))
566 566
567 567 @LazyProperty
568 568 def workdir(self):
569 569 """
570 570 Returns ``Workdir`` instance for this repository.
571 571 """
572 572 return MercurialWorkdir(self)
573 573
574 574 def get_config_value(self, section, name=None, config_file=None):
575 575 """
576 576 Returns configuration value for a given [``section``] and ``name``.
577 577
578 578 :param section: Section we want to retrieve value from
579 579 :param name: Name of configuration we want to retrieve
580 580 :param config_file: A path to file which should be used to retrieve
581 581 configuration from (might also be a list of file paths)
582 582 """
583 583 if config_file is None:
584 584 config_file = []
585 585 elif isinstance(config_file, str):
586 586 config_file = [config_file]
587 587
588 588 config = self._repo.ui
589 589 if config_file:
590 590 config = mercurial.ui.ui()
591 591 for path in config_file:
592 592 config.readconfig(safe_bytes(path))
593 593 value = config.config(safe_bytes(section), safe_bytes(name))
594 594 return value if value is None else safe_str(value)
595 595
596 596 def get_user_name(self, config_file=None):
597 597 """
598 598 Returns user's name from global configuration file.
599 599
600 600 :param config_file: A path to file which should be used to retrieve
601 601 configuration from (might also be a list of file paths)
602 602 """
603 603 username = self.get_config_value('ui', 'username', config_file=config_file)
604 604 if username:
605 605 return author_name(username)
606 606 return None
607 607
608 608 def get_user_email(self, config_file=None):
609 609 """
610 610 Returns user's email from global configuration file.
611 611
612 612 :param config_file: A path to file which should be used to retrieve
613 613 configuration from (might also be a list of file paths)
614 614 """
615 615 username = self.get_config_value('ui', 'username', config_file=config_file)
616 616 if username:
617 617 return author_email(username)
618 618 return None
@@ -1,2550 +1,2550 b''
1 1 # -*- coding: utf-8 -*-
2 2 # This program is free software: you can redistribute it and/or modify
3 3 # it under the terms of the GNU General Public License as published by
4 4 # the Free Software Foundation, either version 3 of the License, or
5 5 # (at your option) any later version.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 """
15 15 kallithea.model.db
16 16 ~~~~~~~~~~~~~~~~~~
17 17
18 18 Database Models for Kallithea
19 19
20 20 This file was forked by the Kallithea project in July 2014.
21 21 Original author and date, and relevant copyright and licensing information is below:
22 22 :created_on: Apr 08, 2010
23 23 :author: marcink
24 24 :copyright: (c) 2013 RhodeCode GmbH, and others.
25 25 :license: GPLv3, see LICENSE.md for more details.
26 26 """
27 27
28 28 import base64
29 29 import collections
30 30 import datetime
31 31 import functools
32 32 import hashlib
33 33 import logging
34 34 import os
35 35 import time
36 36 import traceback
37 37
38 38 import ipaddr
39 39 import sqlalchemy
40 40 from beaker.cache import cache_region, region_invalidate
41 41 from sqlalchemy import Boolean, Column, DateTime, Float, ForeignKey, Index, Integer, LargeBinary, String, Unicode, UnicodeText, UniqueConstraint
42 42 from sqlalchemy.ext.hybrid import hybrid_property
43 43 from sqlalchemy.orm import class_mapper, joinedload, relationship, validates
44 44 from tg.i18n import lazy_ugettext as _
45 45 from webob.exc import HTTPNotFound
46 46
47 47 import kallithea
48 48 from kallithea.lib import ext_json
49 49 from kallithea.lib.caching_query import FromCache
50 50 from kallithea.lib.exceptions import DefaultUserException
51 from kallithea.lib.utils2 import (
52 Optional, ascii_bytes, aslist, get_changeset_safe, get_clone_url, remove_prefix, safe_bytes, safe_int, safe_str, str2bool, urlreadable)
51 from kallithea.lib.utils2 import (Optional, ascii_bytes, aslist, get_changeset_safe, get_clone_url, remove_prefix, safe_bytes, safe_int, safe_str, str2bool,
52 urlreadable)
53 53 from kallithea.lib.vcs import get_backend
54 54 from kallithea.lib.vcs.backends.base import EmptyChangeset
55 55 from kallithea.lib.vcs.utils.helpers import get_scm
56 56 from kallithea.lib.vcs.utils.lazy import LazyProperty
57 57 from kallithea.model.meta import Base, Session
58 58
59 59
60 60 URL_SEP = '/'
61 61 log = logging.getLogger(__name__)
62 62
63 63 #==============================================================================
64 64 # BASE CLASSES
65 65 #==============================================================================
66 66
67 67 def _hash_key(k):
68 68 return hashlib.md5(safe_bytes(k)).hexdigest()
69 69
70 70
71 71 class BaseDbModel(object):
72 72 """
73 73 Base Model for all classes
74 74 """
75 75
76 76 @classmethod
77 77 def _get_keys(cls):
78 78 """return column names for this model """
79 79 # Note: not a normal dict - iterator gives "users.firstname", but keys gives "firstname"
80 80 return class_mapper(cls).c.keys()
81 81
82 82 def get_dict(self):
83 83 """
84 84 return dict with keys and values corresponding
85 85 to this model data """
86 86
87 87 d = {}
88 88 for k in self._get_keys():
89 89 d[k] = getattr(self, k)
90 90
91 91 # also use __json__() if present to get additional fields
92 92 _json_attr = getattr(self, '__json__', None)
93 93 if _json_attr:
94 94 # update with attributes from __json__
95 95 if callable(_json_attr):
96 96 _json_attr = _json_attr()
97 97 for k, val in _json_attr.items():
98 98 d[k] = val
99 99 return d
100 100
101 101 def get_appstruct(self):
102 102 """return list with keys and values tuples corresponding
103 103 to this model data """
104 104
105 105 return [
106 106 (k, getattr(self, k))
107 107 for k in self._get_keys()
108 108 ]
109 109
110 110 def populate_obj(self, populate_dict):
111 111 """populate model with data from given populate_dict"""
112 112
113 113 for k in self._get_keys():
114 114 if k in populate_dict:
115 115 setattr(self, k, populate_dict[k])
116 116
117 117 @classmethod
118 118 def query(cls):
119 119 return Session().query(cls)
120 120
121 121 @classmethod
122 122 def get(cls, id_):
123 123 if id_:
124 124 return cls.query().get(id_)
125 125
126 126 @classmethod
127 127 def guess_instance(cls, value, callback=None):
128 128 """Haphazardly attempt to convert `value` to a `cls` instance.
129 129
130 130 If `value` is None or already a `cls` instance, return it. If `value`
131 131 is a number (or looks like one if you squint just right), assume it's
132 132 a database primary key and let SQLAlchemy sort things out. Otherwise,
133 133 fall back to resolving it using `callback` (if specified); this could
134 134 e.g. be a function that looks up instances by name (though that won't
135 135 work if the name begins with a digit). Otherwise, raise Exception.
136 136 """
137 137
138 138 if value is None:
139 139 return None
140 140 if isinstance(value, cls):
141 141 return value
142 142 if isinstance(value, int):
143 143 return cls.get(value)
144 144 if isinstance(value, str) and value.isdigit():
145 145 return cls.get(int(value))
146 146 if callback is not None:
147 147 return callback(value)
148 148
149 149 raise Exception(
150 150 'given object must be int, long or Instance of %s '
151 151 'got %s, no callback provided' % (cls, type(value))
152 152 )
153 153
154 154 @classmethod
155 155 def get_or_404(cls, id_):
156 156 try:
157 157 id_ = int(id_)
158 158 except (TypeError, ValueError):
159 159 raise HTTPNotFound
160 160
161 161 res = cls.query().get(id_)
162 162 if res is None:
163 163 raise HTTPNotFound
164 164 return res
165 165
166 166 @classmethod
167 167 def delete(cls, id_):
168 168 obj = cls.query().get(id_)
169 169 Session().delete(obj)
170 170
171 171 def __repr__(self):
172 172 return '<DB:%s>' % (self.__class__.__name__)
173 173
174 174
175 175 _table_args_default_dict = {'extend_existing': True,
176 176 'mysql_engine': 'InnoDB',
177 177 'mysql_charset': 'utf8',
178 178 'sqlite_autoincrement': True,
179 179 }
180 180
181 181 class Setting(Base, BaseDbModel):
182 182 __tablename__ = 'settings'
183 183 __table_args__ = (
184 184 _table_args_default_dict,
185 185 )
186 186
187 187 SETTINGS_TYPES = {
188 188 'str': safe_bytes,
189 189 'int': safe_int,
190 190 'unicode': safe_str,
191 191 'bool': str2bool,
192 192 'list': functools.partial(aslist, sep=',')
193 193 }
194 194 DEFAULT_UPDATE_URL = ''
195 195
196 196 app_settings_id = Column(Integer(), primary_key=True)
197 197 app_settings_name = Column(String(255), nullable=False, unique=True)
198 198 _app_settings_value = Column("app_settings_value", Unicode(4096), nullable=False)
199 199 _app_settings_type = Column("app_settings_type", String(255), nullable=True) # FIXME: not nullable?
200 200
201 201 def __init__(self, key='', val='', type='unicode'):
202 202 self.app_settings_name = key
203 203 self.app_settings_value = val
204 204 self.app_settings_type = type
205 205
206 206 @validates('_app_settings_value')
207 207 def validate_settings_value(self, key, val):
208 208 assert isinstance(val, str)
209 209 return val
210 210
211 211 @hybrid_property
212 212 def app_settings_value(self):
213 213 v = self._app_settings_value
214 214 _type = self.app_settings_type
215 215 converter = self.SETTINGS_TYPES.get(_type) or self.SETTINGS_TYPES['unicode']
216 216 return converter(v)
217 217
218 218 @app_settings_value.setter
219 219 def app_settings_value(self, val):
220 220 """
221 221 Setter that will always make sure we use str in app_settings_value
222 222 """
223 223 self._app_settings_value = safe_str(val)
224 224
225 225 @hybrid_property
226 226 def app_settings_type(self):
227 227 return self._app_settings_type
228 228
229 229 @app_settings_type.setter
230 230 def app_settings_type(self, val):
231 231 if val not in self.SETTINGS_TYPES:
232 232 raise Exception('type must be one of %s got %s'
233 233 % (list(self.SETTINGS_TYPES), val))
234 234 self._app_settings_type = val
235 235
236 236 def __repr__(self):
237 237 return "<%s %s.%s=%r>" % (
238 238 self.__class__.__name__,
239 239 self.app_settings_name, self.app_settings_type, self.app_settings_value
240 240 )
241 241
242 242 @classmethod
243 243 def get_by_name(cls, key):
244 244 return cls.query() \
245 245 .filter(cls.app_settings_name == key).scalar()
246 246
247 247 @classmethod
248 248 def get_by_name_or_create(cls, key, val='', type='unicode'):
249 249 res = cls.get_by_name(key)
250 250 if res is None:
251 251 res = cls(key, val, type)
252 252 return res
253 253
254 254 @classmethod
255 255 def create_or_update(cls, key, val=Optional(''), type=Optional('unicode')):
256 256 """
257 257 Creates or updates Kallithea setting. If updates are triggered, it will only
258 258 update parameters that are explicitly set. Optional instance will be skipped.
259 259
260 260 :param key:
261 261 :param val:
262 262 :param type:
263 263 :return:
264 264 """
265 265 res = cls.get_by_name(key)
266 266 if res is None:
267 267 val = Optional.extract(val)
268 268 type = Optional.extract(type)
269 269 res = cls(key, val, type)
270 270 Session().add(res)
271 271 else:
272 272 res.app_settings_name = key
273 273 if not isinstance(val, Optional):
274 274 # update if set
275 275 res.app_settings_value = val
276 276 if not isinstance(type, Optional):
277 277 # update if set
278 278 res.app_settings_type = type
279 279 return res
280 280
281 281 @classmethod
282 282 def get_app_settings(cls, cache=False):
283 283
284 284 ret = cls.query()
285 285
286 286 if cache:
287 287 ret = ret.options(FromCache("sql_cache_short", "get_hg_settings"))
288 288
289 289 if ret is None:
290 290 raise Exception('Could not get application settings !')
291 291 settings = {}
292 292 for each in ret:
293 293 settings[each.app_settings_name] = \
294 294 each.app_settings_value
295 295
296 296 return settings
297 297
298 298 @classmethod
299 299 def get_auth_settings(cls, cache=False):
300 300 ret = cls.query() \
301 301 .filter(cls.app_settings_name.startswith('auth_')).all()
302 302 fd = {}
303 303 for row in ret:
304 304 fd[row.app_settings_name] = row.app_settings_value
305 305 return fd
306 306
307 307 @classmethod
308 308 def get_default_repo_settings(cls, cache=False, strip_prefix=False):
309 309 ret = cls.query() \
310 310 .filter(cls.app_settings_name.startswith('default_')).all()
311 311 fd = {}
312 312 for row in ret:
313 313 key = row.app_settings_name
314 314 if strip_prefix:
315 315 key = remove_prefix(key, prefix='default_')
316 316 fd.update({key: row.app_settings_value})
317 317
318 318 return fd
319 319
320 320 @classmethod
321 321 def get_server_info(cls):
322 322 import pkg_resources
323 323 import platform
324 324 from kallithea.lib.utils import check_git_version
325 325 mods = [(p.project_name, p.version) for p in pkg_resources.working_set]
326 326 info = {
327 327 'modules': sorted(mods, key=lambda k: k[0].lower()),
328 328 'py_version': platform.python_version(),
329 329 'platform': platform.platform(),
330 330 'kallithea_version': kallithea.__version__,
331 331 'git_version': str(check_git_version()),
332 332 'git_path': kallithea.CONFIG.get('git_path')
333 333 }
334 334 return info
335 335
336 336
337 337 class Ui(Base, BaseDbModel):
338 338 __tablename__ = 'ui'
339 339 __table_args__ = (
340 340 # FIXME: ui_key as key is wrong and should be removed when the corresponding
341 341 # Ui.get_by_key has been replaced by the composite key
342 342 UniqueConstraint('ui_key'),
343 343 UniqueConstraint('ui_section', 'ui_key'),
344 344 _table_args_default_dict,
345 345 )
346 346
347 347 HOOK_UPDATE = 'changegroup.update'
348 348 HOOK_REPO_SIZE = 'changegroup.repo_size'
349 349
350 350 ui_id = Column(Integer(), primary_key=True)
351 351 ui_section = Column(String(255), nullable=False)
352 352 ui_key = Column(String(255), nullable=False)
353 353 ui_value = Column(String(255), nullable=True) # FIXME: not nullable?
354 354 ui_active = Column(Boolean(), nullable=False, default=True)
355 355
356 356 @classmethod
357 357 def get_by_key(cls, section, key):
358 358 """ Return specified Ui object, or None if not found. """
359 359 return cls.query().filter_by(ui_section=section, ui_key=key).scalar()
360 360
361 361 @classmethod
362 362 def get_or_create(cls, section, key):
363 363 """ Return specified Ui object, creating it if necessary. """
364 364 setting = cls.get_by_key(section, key)
365 365 if setting is None:
366 366 setting = cls(ui_section=section, ui_key=key)
367 367 Session().add(setting)
368 368 return setting
369 369
370 370 @classmethod
371 371 def get_builtin_hooks(cls):
372 372 q = cls.query()
373 373 q = q.filter(cls.ui_key.in_([cls.HOOK_UPDATE, cls.HOOK_REPO_SIZE]))
374 374 q = q.filter(cls.ui_section == 'hooks')
375 375 return q.all()
376 376
377 377 @classmethod
378 378 def get_custom_hooks(cls):
379 379 q = cls.query()
380 380 q = q.filter(~cls.ui_key.in_([cls.HOOK_UPDATE, cls.HOOK_REPO_SIZE]))
381 381 q = q.filter(cls.ui_section == 'hooks')
382 382 return q.all()
383 383
384 384 @classmethod
385 385 def get_repos_location(cls):
386 386 return cls.get_by_key('paths', '/').ui_value
387 387
388 388 @classmethod
389 389 def create_or_update_hook(cls, key, val):
390 390 new_ui = cls.get_or_create('hooks', key)
391 391 new_ui.ui_active = True
392 392 new_ui.ui_value = val
393 393
394 394 def __repr__(self):
395 395 return '<%s %s.%s=%r>' % (
396 396 self.__class__.__name__,
397 397 self.ui_section, self.ui_key, self.ui_value)
398 398
399 399
400 400 class User(Base, BaseDbModel):
401 401 __tablename__ = 'users'
402 402 __table_args__ = (
403 403 Index('u_username_idx', 'username'),
404 404 Index('u_email_idx', 'email'),
405 405 _table_args_default_dict,
406 406 )
407 407
408 408 DEFAULT_USER = 'default'
409 409 DEFAULT_GRAVATAR_URL = 'https://secure.gravatar.com/avatar/{md5email}?d=identicon&s={size}'
410 410 # The name of the default auth type in extern_type, 'internal' lives in auth_internal.py
411 411 DEFAULT_AUTH_TYPE = 'internal'
412 412
413 413 user_id = Column(Integer(), primary_key=True)
414 414 username = Column(String(255), nullable=False, unique=True)
415 415 password = Column(String(255), nullable=False)
416 416 active = Column(Boolean(), nullable=False, default=True)
417 417 admin = Column(Boolean(), nullable=False, default=False)
418 418 name = Column("firstname", Unicode(255), nullable=False)
419 419 lastname = Column(Unicode(255), nullable=False)
420 420 _email = Column("email", String(255), nullable=True, unique=True) # FIXME: not nullable?
421 421 last_login = Column(DateTime(timezone=False), nullable=True)
422 422 extern_type = Column(String(255), nullable=True) # FIXME: not nullable?
423 423 extern_name = Column(String(255), nullable=True) # FIXME: not nullable?
424 424 api_key = Column(String(255), nullable=False)
425 425 created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
426 426 _user_data = Column("user_data", LargeBinary(), nullable=True) # JSON data # FIXME: not nullable?
427 427
428 428 user_log = relationship('UserLog')
429 429 user_perms = relationship('UserToPerm', primaryjoin="User.user_id==UserToPerm.user_id", cascade='all')
430 430
431 431 repositories = relationship('Repository')
432 432 repo_groups = relationship('RepoGroup')
433 433 user_groups = relationship('UserGroup')
434 434 user_followers = relationship('UserFollowing', primaryjoin='UserFollowing.follows_user_id==User.user_id', cascade='all')
435 435 followings = relationship('UserFollowing', primaryjoin='UserFollowing.user_id==User.user_id', cascade='all')
436 436
437 437 repo_to_perm = relationship('UserRepoToPerm', primaryjoin='UserRepoToPerm.user_id==User.user_id', cascade='all')
438 438 repo_group_to_perm = relationship('UserRepoGroupToPerm', primaryjoin='UserRepoGroupToPerm.user_id==User.user_id', cascade='all')
439 439
440 440 group_member = relationship('UserGroupMember', cascade='all')
441 441
442 442 # comments created by this user
443 443 user_comments = relationship('ChangesetComment', cascade='all')
444 444 # extra emails for this user
445 445 user_emails = relationship('UserEmailMap', cascade='all')
446 446 # extra API keys
447 447 user_api_keys = relationship('UserApiKeys', cascade='all')
448 448 ssh_keys = relationship('UserSshKeys', cascade='all')
449 449
450 450 @hybrid_property
451 451 def email(self):
452 452 return self._email
453 453
454 454 @email.setter
455 455 def email(self, val):
456 456 self._email = val.lower() if val else None
457 457
458 458 @property
459 459 def firstname(self):
460 460 # alias for future
461 461 return self.name
462 462
463 463 @property
464 464 def emails(self):
465 465 other = UserEmailMap.query().filter(UserEmailMap.user == self).all()
466 466 return [self.email] + [x.email for x in other]
467 467
468 468 @property
469 469 def api_keys(self):
470 470 other = UserApiKeys.query().filter(UserApiKeys.user == self).all()
471 471 return [self.api_key] + [x.api_key for x in other]
472 472
473 473 @property
474 474 def ip_addresses(self):
475 475 ret = UserIpMap.query().filter(UserIpMap.user == self).all()
476 476 return [x.ip_addr for x in ret]
477 477
478 478 @property
479 479 def full_name(self):
480 480 return '%s %s' % (self.firstname, self.lastname)
481 481
482 482 @property
483 483 def full_name_or_username(self):
484 484 """
485 485 Show full name.
486 486 If full name is not set, fall back to username.
487 487 """
488 488 return ('%s %s' % (self.firstname, self.lastname)
489 489 if (self.firstname and self.lastname) else self.username)
490 490
491 491 @property
492 492 def full_name_and_username(self):
493 493 """
494 494 Show full name and username as 'Firstname Lastname (username)'.
495 495 If full name is not set, fall back to username.
496 496 """
497 497 return ('%s %s (%s)' % (self.firstname, self.lastname, self.username)
498 498 if (self.firstname and self.lastname) else self.username)
499 499
500 500 @property
501 501 def full_contact(self):
502 502 return '%s %s <%s>' % (self.firstname, self.lastname, self.email)
503 503
504 504 @property
505 505 def short_contact(self):
506 506 return '%s %s' % (self.firstname, self.lastname)
507 507
508 508 @property
509 509 def is_admin(self):
510 510 return self.admin
511 511
512 512 @hybrid_property
513 513 def is_default_user(self):
514 514 return self.username == User.DEFAULT_USER
515 515
516 516 @hybrid_property
517 517 def user_data(self):
518 518 if not self._user_data:
519 519 return {}
520 520
521 521 try:
522 522 return ext_json.loads(self._user_data)
523 523 except TypeError:
524 524 return {}
525 525
526 526 @user_data.setter
527 527 def user_data(self, val):
528 528 try:
529 529 self._user_data = ascii_bytes(ext_json.dumps(val))
530 530 except Exception:
531 531 log.error(traceback.format_exc())
532 532
533 533 def __repr__(self):
534 534 return "<%s %s: %r')>" % (self.__class__.__name__, self.user_id, self.username)
535 535
536 536 @classmethod
537 537 def guess_instance(cls, value):
538 538 return super(User, cls).guess_instance(value, User.get_by_username)
539 539
540 540 @classmethod
541 541 def get_or_404(cls, id_, allow_default=True):
542 542 '''
543 543 Overridden version of BaseDbModel.get_or_404, with an extra check on
544 544 the default user.
545 545 '''
546 546 user = super(User, cls).get_or_404(id_)
547 547 if not allow_default and user.is_default_user:
548 548 raise DefaultUserException()
549 549 return user
550 550
551 551 @classmethod
552 552 def get_by_username_or_email(cls, username_or_email, case_insensitive=False, cache=False):
553 553 """
554 554 For anything that looks like an email address, look up by the email address (matching
555 555 case insensitively).
556 556 For anything else, try to look up by the user name.
557 557
558 558 This assumes no normal username can have '@' symbol.
559 559 """
560 560 if '@' in username_or_email:
561 561 return User.get_by_email(username_or_email, cache=cache)
562 562 else:
563 563 return User.get_by_username(username_or_email, case_insensitive=case_insensitive, cache=cache)
564 564
565 565 @classmethod
566 566 def get_by_username(cls, username, case_insensitive=False, cache=False):
567 567 if case_insensitive:
568 568 q = cls.query().filter(sqlalchemy.func.lower(cls.username) == sqlalchemy.func.lower(username))
569 569 else:
570 570 q = cls.query().filter(cls.username == username)
571 571
572 572 if cache:
573 573 q = q.options(FromCache(
574 574 "sql_cache_short",
575 575 "get_user_%s" % _hash_key(username)
576 576 )
577 577 )
578 578 return q.scalar()
579 579
580 580 @classmethod
581 581 def get_by_api_key(cls, api_key, cache=False, fallback=True):
582 582 if len(api_key) != 40 or not api_key.isalnum():
583 583 return None
584 584
585 585 q = cls.query().filter(cls.api_key == api_key)
586 586
587 587 if cache:
588 588 q = q.options(FromCache("sql_cache_short",
589 589 "get_api_key_%s" % api_key))
590 590 res = q.scalar()
591 591
592 592 if fallback and not res:
593 593 # fallback to additional keys
594 594 _res = UserApiKeys.query().filter_by(api_key=api_key, is_expired=False).first()
595 595 if _res:
596 596 res = _res.user
597 597 if res is None or not res.active or res.is_default_user:
598 598 return None
599 599 return res
600 600
601 601 @classmethod
602 602 def get_by_email(cls, email, cache=False):
603 603 q = cls.query().filter(sqlalchemy.func.lower(cls.email) == sqlalchemy.func.lower(email))
604 604
605 605 if cache:
606 606 q = q.options(FromCache("sql_cache_short",
607 607 "get_email_key_%s" % email))
608 608
609 609 ret = q.scalar()
610 610 if ret is None:
611 611 q = UserEmailMap.query()
612 612 # try fetching in alternate email map
613 613 q = q.filter(sqlalchemy.func.lower(UserEmailMap.email) == sqlalchemy.func.lower(email))
614 614 q = q.options(joinedload(UserEmailMap.user))
615 615 if cache:
616 616 q = q.options(FromCache("sql_cache_short",
617 617 "get_email_map_key_%s" % email))
618 618 ret = getattr(q.scalar(), 'user', None)
619 619
620 620 return ret
621 621
622 622 @classmethod
623 623 def get_from_cs_author(cls, author):
624 624 """
625 625 Tries to get User objects out of commit author string
626 626
627 627 :param author:
628 628 """
629 629 from kallithea.lib.helpers import email, author_name
630 630 # Valid email in the attribute passed, see if they're in the system
631 631 _email = email(author)
632 632 if _email:
633 633 user = cls.get_by_email(_email)
634 634 if user is not None:
635 635 return user
636 636 # Maybe we can match by username?
637 637 _author = author_name(author)
638 638 user = cls.get_by_username(_author, case_insensitive=True)
639 639 if user is not None:
640 640 return user
641 641
642 642 def update_lastlogin(self):
643 643 """Update user lastlogin"""
644 644 self.last_login = datetime.datetime.now()
645 645 log.debug('updated user %s lastlogin', self.username)
646 646
647 647 @classmethod
648 648 def get_first_admin(cls):
649 649 user = User.query().filter(User.admin == True).first()
650 650 if user is None:
651 651 raise Exception('Missing administrative account!')
652 652 return user
653 653
654 654 @classmethod
655 655 def get_default_user(cls, cache=False):
656 656 user = User.get_by_username(User.DEFAULT_USER, cache=cache)
657 657 if user is None:
658 658 raise Exception('Missing default account!')
659 659 return user
660 660
661 661 def get_api_data(self, details=False):
662 662 """
663 663 Common function for generating user related data for API
664 664 """
665 665 user = self
666 666 data = dict(
667 667 user_id=user.user_id,
668 668 username=user.username,
669 669 firstname=user.name,
670 670 lastname=user.lastname,
671 671 email=user.email,
672 672 emails=user.emails,
673 673 active=user.active,
674 674 admin=user.admin,
675 675 )
676 676 if details:
677 677 data.update(dict(
678 678 extern_type=user.extern_type,
679 679 extern_name=user.extern_name,
680 680 api_key=user.api_key,
681 681 api_keys=user.api_keys,
682 682 last_login=user.last_login,
683 683 ip_addresses=user.ip_addresses
684 684 ))
685 685 return data
686 686
687 687 def __json__(self):
688 688 data = dict(
689 689 full_name=self.full_name,
690 690 full_name_or_username=self.full_name_or_username,
691 691 short_contact=self.short_contact,
692 692 full_contact=self.full_contact
693 693 )
694 694 data.update(self.get_api_data())
695 695 return data
696 696
697 697
698 698 class UserApiKeys(Base, BaseDbModel):
699 699 __tablename__ = 'user_api_keys'
700 700 __table_args__ = (
701 701 Index('uak_api_key_idx', 'api_key'),
702 702 Index('uak_api_key_expires_idx', 'api_key', 'expires'),
703 703 _table_args_default_dict,
704 704 )
705 705
706 706 user_api_key_id = Column(Integer(), primary_key=True)
707 707 user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)
708 708 api_key = Column(String(255), nullable=False, unique=True)
709 709 description = Column(UnicodeText(), nullable=False)
710 710 expires = Column(Float(53), nullable=False)
711 711 created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
712 712
713 713 user = relationship('User')
714 714
715 715 @hybrid_property
716 716 def is_expired(self):
717 717 return (self.expires != -1) & (time.time() > self.expires)
718 718
719 719
720 720 class UserEmailMap(Base, BaseDbModel):
721 721 __tablename__ = 'user_email_map'
722 722 __table_args__ = (
723 723 Index('uem_email_idx', 'email'),
724 724 _table_args_default_dict,
725 725 )
726 726
727 727 email_id = Column(Integer(), primary_key=True)
728 728 user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)
729 729 _email = Column("email", String(255), nullable=False, unique=True)
730 730 user = relationship('User')
731 731
732 732 @validates('_email')
733 733 def validate_email(self, key, email):
734 734 # check if this email is not main one
735 735 main_email = Session().query(User).filter(User.email == email).scalar()
736 736 if main_email is not None:
737 737 raise AttributeError('email %s is present is user table' % email)
738 738 return email
739 739
740 740 @hybrid_property
741 741 def email(self):
742 742 return self._email
743 743
744 744 @email.setter
745 745 def email(self, val):
746 746 self._email = val.lower() if val else None
747 747
748 748
749 749 class UserIpMap(Base, BaseDbModel):
750 750 __tablename__ = 'user_ip_map'
751 751 __table_args__ = (
752 752 UniqueConstraint('user_id', 'ip_addr'),
753 753 _table_args_default_dict,
754 754 )
755 755
756 756 ip_id = Column(Integer(), primary_key=True)
757 757 user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)
758 758 ip_addr = Column(String(255), nullable=False)
759 759 active = Column(Boolean(), nullable=False, default=True)
760 760 user = relationship('User')
761 761
762 762 @classmethod
763 763 def _get_ip_range(cls, ip_addr):
764 764 net = ipaddr.IPNetwork(address=ip_addr)
765 765 return [str(net.network), str(net.broadcast)]
766 766
767 767 def __json__(self):
768 768 return dict(
769 769 ip_addr=self.ip_addr,
770 770 ip_range=self._get_ip_range(self.ip_addr)
771 771 )
772 772
773 773 def __repr__(self):
774 774 return "<%s %s: %s>" % (self.__class__.__name__, self.user_id, self.ip_addr)
775 775
776 776
777 777 class UserLog(Base, BaseDbModel):
778 778 __tablename__ = 'user_logs'
779 779 __table_args__ = (
780 780 _table_args_default_dict,
781 781 )
782 782
783 783 user_log_id = Column(Integer(), primary_key=True)
784 784 user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=True)
785 785 username = Column(String(255), nullable=False)
786 786 repository_id = Column(Integer(), ForeignKey('repositories.repo_id'), nullable=True)
787 787 repository_name = Column(Unicode(255), nullable=False)
788 788 user_ip = Column(String(255), nullable=True)
789 789 action = Column(UnicodeText(), nullable=False)
790 790 action_date = Column(DateTime(timezone=False), nullable=False)
791 791
792 792 def __repr__(self):
793 793 return "<%s %r: %r')>" % (self.__class__.__name__,
794 794 self.repository_name,
795 795 self.action)
796 796
797 797 @property
798 798 def action_as_day(self):
799 799 return datetime.date(*self.action_date.timetuple()[:3])
800 800
801 801 user = relationship('User')
802 802 repository = relationship('Repository', cascade='')
803 803
804 804
805 805 class UserGroup(Base, BaseDbModel):
806 806 __tablename__ = 'users_groups'
807 807 __table_args__ = (
808 808 _table_args_default_dict,
809 809 )
810 810
811 811 users_group_id = Column(Integer(), primary_key=True)
812 812 users_group_name = Column(Unicode(255), nullable=False, unique=True)
813 813 user_group_description = Column(Unicode(10000), nullable=True) # FIXME: not nullable?
814 814 users_group_active = Column(Boolean(), nullable=False)
815 815 owner_id = Column('user_id', Integer(), ForeignKey('users.user_id'), nullable=False)
816 816 created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
817 817 _group_data = Column("group_data", LargeBinary(), nullable=True) # JSON data # FIXME: not nullable?
818 818
819 819 members = relationship('UserGroupMember', cascade="all, delete-orphan")
820 820 users_group_to_perm = relationship('UserGroupToPerm', cascade='all')
821 821 users_group_repo_to_perm = relationship('UserGroupRepoToPerm', cascade='all')
822 822 users_group_repo_group_to_perm = relationship('UserGroupRepoGroupToPerm', cascade='all')
823 823 user_user_group_to_perm = relationship('UserUserGroupToPerm ', cascade='all')
824 824 user_group_user_group_to_perm = relationship('UserGroupUserGroupToPerm ', primaryjoin="UserGroupUserGroupToPerm.target_user_group_id==UserGroup.users_group_id", cascade='all')
825 825
826 826 owner = relationship('User')
827 827
828 828 @hybrid_property
829 829 def group_data(self):
830 830 if not self._group_data:
831 831 return {}
832 832
833 833 try:
834 834 return ext_json.loads(self._group_data)
835 835 except TypeError:
836 836 return {}
837 837
838 838 @group_data.setter
839 839 def group_data(self, val):
840 840 try:
841 841 self._group_data = ascii_bytes(ext_json.dumps(val))
842 842 except Exception:
843 843 log.error(traceback.format_exc())
844 844
845 845 def __repr__(self):
846 846 return "<%s %s: %r')>" % (self.__class__.__name__,
847 847 self.users_group_id,
848 848 self.users_group_name)
849 849
850 850 @classmethod
851 851 def guess_instance(cls, value):
852 852 return super(UserGroup, cls).guess_instance(value, UserGroup.get_by_group_name)
853 853
854 854 @classmethod
855 855 def get_by_group_name(cls, group_name, cache=False,
856 856 case_insensitive=False):
857 857 if case_insensitive:
858 858 q = cls.query().filter(sqlalchemy.func.lower(cls.users_group_name) == sqlalchemy.func.lower(group_name))
859 859 else:
860 860 q = cls.query().filter(cls.users_group_name == group_name)
861 861 if cache:
862 862 q = q.options(FromCache(
863 863 "sql_cache_short",
864 864 "get_group_%s" % _hash_key(group_name)
865 865 )
866 866 )
867 867 return q.scalar()
868 868
869 869 @classmethod
870 870 def get(cls, user_group_id, cache=False):
871 871 user_group = cls.query()
872 872 if cache:
873 873 user_group = user_group.options(FromCache("sql_cache_short",
874 874 "get_users_group_%s" % user_group_id))
875 875 return user_group.get(user_group_id)
876 876
877 877 def get_api_data(self, with_members=True):
878 878 user_group = self
879 879
880 880 data = dict(
881 881 users_group_id=user_group.users_group_id,
882 882 group_name=user_group.users_group_name,
883 883 group_description=user_group.user_group_description,
884 884 active=user_group.users_group_active,
885 885 owner=user_group.owner.username,
886 886 )
887 887 if with_members:
888 888 data['members'] = [
889 889 ugm.user.get_api_data()
890 890 for ugm in user_group.members
891 891 ]
892 892
893 893 return data
894 894
895 895
896 896 class UserGroupMember(Base, BaseDbModel):
897 897 __tablename__ = 'users_groups_members'
898 898 __table_args__ = (
899 899 _table_args_default_dict,
900 900 )
901 901
902 902 users_group_member_id = Column(Integer(), primary_key=True)
903 903 users_group_id = Column(Integer(), ForeignKey('users_groups.users_group_id'), nullable=False)
904 904 user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)
905 905
906 906 user = relationship('User')
907 907 users_group = relationship('UserGroup')
908 908
909 909 def __init__(self, gr_id='', u_id=''):
910 910 self.users_group_id = gr_id
911 911 self.user_id = u_id
912 912
913 913
914 914 class RepositoryField(Base, BaseDbModel):
915 915 __tablename__ = 'repositories_fields'
916 916 __table_args__ = (
917 917 UniqueConstraint('repository_id', 'field_key'), # no-multi field
918 918 _table_args_default_dict,
919 919 )
920 920
921 921 PREFIX = 'ex_' # prefix used in form to not conflict with already existing fields
922 922
923 923 repo_field_id = Column(Integer(), primary_key=True)
924 924 repository_id = Column(Integer(), ForeignKey('repositories.repo_id'), nullable=False)
925 925 field_key = Column(String(250), nullable=False)
926 926 field_label = Column(String(1024), nullable=False)
927 927 field_value = Column(String(10000), nullable=False)
928 928 field_desc = Column(String(1024), nullable=False)
929 929 field_type = Column(String(255), nullable=False)
930 930 created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
931 931
932 932 repository = relationship('Repository')
933 933
934 934 @property
935 935 def field_key_prefixed(self):
936 936 return 'ex_%s' % self.field_key
937 937
938 938 @classmethod
939 939 def un_prefix_key(cls, key):
940 940 if key.startswith(cls.PREFIX):
941 941 return key[len(cls.PREFIX):]
942 942 return key
943 943
944 944 @classmethod
945 945 def get_by_key_name(cls, key, repo):
946 946 row = cls.query() \
947 947 .filter(cls.repository == repo) \
948 948 .filter(cls.field_key == key).scalar()
949 949 return row
950 950
951 951
952 952 class Repository(Base, BaseDbModel):
953 953 __tablename__ = 'repositories'
954 954 __table_args__ = (
955 955 Index('r_repo_name_idx', 'repo_name'),
956 956 _table_args_default_dict,
957 957 )
958 958
959 959 DEFAULT_CLONE_URI = '{scheme}://{user}@{netloc}/{repo}'
960 960 DEFAULT_CLONE_SSH = 'ssh://{system_user}@{hostname}/{repo}'
961 961
962 962 STATE_CREATED = 'repo_state_created'
963 963 STATE_PENDING = 'repo_state_pending'
964 964 STATE_ERROR = 'repo_state_error'
965 965
966 966 repo_id = Column(Integer(), primary_key=True)
967 967 repo_name = Column(Unicode(255), nullable=False, unique=True)
968 968 repo_state = Column(String(255), nullable=False)
969 969
970 970 clone_uri = Column(String(255), nullable=True) # FIXME: not nullable?
971 971 repo_type = Column(String(255), nullable=False) # 'hg' or 'git'
972 972 owner_id = Column('user_id', Integer(), ForeignKey('users.user_id'), nullable=False)
973 973 private = Column(Boolean(), nullable=False)
974 974 enable_statistics = Column("statistics", Boolean(), nullable=False, default=True)
975 975 enable_downloads = Column("downloads", Boolean(), nullable=False, default=True)
976 976 description = Column(Unicode(10000), nullable=False)
977 977 created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
978 978 updated_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
979 979 _landing_revision = Column("landing_revision", String(255), nullable=False)
980 980 _changeset_cache = Column("changeset_cache", LargeBinary(), nullable=True) # JSON data # FIXME: not nullable?
981 981
982 982 fork_id = Column(Integer(), ForeignKey('repositories.repo_id'), nullable=True)
983 983 group_id = Column(Integer(), ForeignKey('groups.group_id'), nullable=True)
984 984
985 985 owner = relationship('User')
986 986 fork = relationship('Repository', remote_side=repo_id)
987 987 group = relationship('RepoGroup')
988 988 repo_to_perm = relationship('UserRepoToPerm', cascade='all', order_by='UserRepoToPerm.repo_to_perm_id')
989 989 users_group_to_perm = relationship('UserGroupRepoToPerm', cascade='all')
990 990 stats = relationship('Statistics', cascade='all', uselist=False)
991 991
992 992 followers = relationship('UserFollowing',
993 993 primaryjoin='UserFollowing.follows_repository_id==Repository.repo_id',
994 994 cascade='all')
995 995 extra_fields = relationship('RepositoryField',
996 996 cascade="all, delete-orphan")
997 997
998 998 logs = relationship('UserLog')
999 999 comments = relationship('ChangesetComment', cascade="all, delete-orphan")
1000 1000
1001 1001 pull_requests_org = relationship('PullRequest',
1002 1002 primaryjoin='PullRequest.org_repo_id==Repository.repo_id',
1003 1003 cascade="all, delete-orphan")
1004 1004
1005 1005 pull_requests_other = relationship('PullRequest',
1006 1006 primaryjoin='PullRequest.other_repo_id==Repository.repo_id',
1007 1007 cascade="all, delete-orphan")
1008 1008
1009 1009 def __repr__(self):
1010 1010 return "<%s %s: %r>" % (self.__class__.__name__,
1011 1011 self.repo_id, self.repo_name)
1012 1012
1013 1013 @hybrid_property
1014 1014 def landing_rev(self):
1015 1015 # always should return [rev_type, rev]
1016 1016 if self._landing_revision:
1017 1017 _rev_info = self._landing_revision.split(':')
1018 1018 if len(_rev_info) < 2:
1019 1019 _rev_info.insert(0, 'rev')
1020 1020 return [_rev_info[0], _rev_info[1]]
1021 1021 return [None, None]
1022 1022
1023 1023 @landing_rev.setter
1024 1024 def landing_rev(self, val):
1025 1025 if ':' not in val:
1026 1026 raise ValueError('value must be delimited with `:` and consist '
1027 1027 'of <rev_type>:<rev>, got %s instead' % val)
1028 1028 self._landing_revision = val
1029 1029
1030 1030 @hybrid_property
1031 1031 def changeset_cache(self):
1032 1032 try:
1033 1033 cs_cache = ext_json.loads(self._changeset_cache) # might raise on bad data
1034 1034 cs_cache['raw_id'] # verify data, raise exception on error
1035 1035 return cs_cache
1036 1036 except (TypeError, KeyError, ValueError):
1037 1037 return EmptyChangeset().__json__()
1038 1038
1039 1039 @changeset_cache.setter
1040 1040 def changeset_cache(self, val):
1041 1041 try:
1042 1042 self._changeset_cache = ascii_bytes(ext_json.dumps(val))
1043 1043 except Exception:
1044 1044 log.error(traceback.format_exc())
1045 1045
1046 1046 @classmethod
1047 1047 def query(cls, sorted=False):
1048 1048 """Add Repository-specific helpers for common query constructs.
1049 1049
1050 1050 sorted: if True, apply the default ordering (name, case insensitive).
1051 1051 """
1052 1052 q = super(Repository, cls).query()
1053 1053
1054 1054 if sorted:
1055 1055 q = q.order_by(sqlalchemy.func.lower(Repository.repo_name))
1056 1056
1057 1057 return q
1058 1058
1059 1059 @classmethod
1060 1060 def url_sep(cls):
1061 1061 return URL_SEP
1062 1062
1063 1063 @classmethod
1064 1064 def normalize_repo_name(cls, repo_name):
1065 1065 """
1066 1066 Normalizes os specific repo_name to the format internally stored inside
1067 1067 database using URL_SEP
1068 1068
1069 1069 :param cls:
1070 1070 :param repo_name:
1071 1071 """
1072 1072 return cls.url_sep().join(repo_name.split(os.sep))
1073 1073
1074 1074 @classmethod
1075 1075 def guess_instance(cls, value):
1076 1076 return super(Repository, cls).guess_instance(value, Repository.get_by_repo_name)
1077 1077
1078 1078 @classmethod
1079 1079 def get_by_repo_name(cls, repo_name, case_insensitive=False):
1080 1080 """Get the repo, defaulting to database case sensitivity.
1081 1081 case_insensitive will be slower and should only be specified if necessary."""
1082 1082 if case_insensitive:
1083 1083 q = Session().query(cls).filter(sqlalchemy.func.lower(cls.repo_name) == sqlalchemy.func.lower(repo_name))
1084 1084 else:
1085 1085 q = Session().query(cls).filter(cls.repo_name == repo_name)
1086 1086 q = q.options(joinedload(Repository.fork)) \
1087 1087 .options(joinedload(Repository.owner)) \
1088 1088 .options(joinedload(Repository.group))
1089 1089 return q.scalar()
1090 1090
1091 1091 @classmethod
1092 1092 def get_by_full_path(cls, repo_full_path):
1093 1093 base_full_path = os.path.realpath(cls.base_path())
1094 1094 repo_full_path = os.path.realpath(repo_full_path)
1095 1095 assert repo_full_path.startswith(base_full_path + os.path.sep)
1096 1096 repo_name = repo_full_path[len(base_full_path) + 1:]
1097 1097 repo_name = cls.normalize_repo_name(repo_name)
1098 1098 return cls.get_by_repo_name(repo_name.strip(URL_SEP))
1099 1099
1100 1100 @classmethod
1101 1101 def get_repo_forks(cls, repo_id):
1102 1102 return cls.query().filter(Repository.fork_id == repo_id)
1103 1103
1104 1104 @classmethod
1105 1105 def base_path(cls):
1106 1106 """
1107 1107 Returns base path where all repos are stored
1108 1108
1109 1109 :param cls:
1110 1110 """
1111 1111 q = Session().query(Ui) \
1112 1112 .filter(Ui.ui_key == cls.url_sep())
1113 1113 q = q.options(FromCache("sql_cache_short", "repository_repo_path"))
1114 1114 return q.one().ui_value
1115 1115
1116 1116 @property
1117 1117 def forks(self):
1118 1118 """
1119 1119 Return forks of this repo
1120 1120 """
1121 1121 return Repository.get_repo_forks(self.repo_id)
1122 1122
1123 1123 @property
1124 1124 def parent(self):
1125 1125 """
1126 1126 Returns fork parent
1127 1127 """
1128 1128 return self.fork
1129 1129
1130 1130 @property
1131 1131 def just_name(self):
1132 1132 return self.repo_name.split(Repository.url_sep())[-1]
1133 1133
1134 1134 @property
1135 1135 def groups_with_parents(self):
1136 1136 groups = []
1137 1137 group = self.group
1138 1138 while group is not None:
1139 1139 groups.append(group)
1140 1140 group = group.parent_group
1141 1141 assert group not in groups, group # avoid recursion on bad db content
1142 1142 groups.reverse()
1143 1143 return groups
1144 1144
1145 1145 @LazyProperty
1146 1146 def repo_path(self):
1147 1147 """
1148 1148 Returns base full path for that repository means where it actually
1149 1149 exists on a filesystem
1150 1150 """
1151 1151 q = Session().query(Ui).filter(Ui.ui_key ==
1152 1152 Repository.url_sep())
1153 1153 q = q.options(FromCache("sql_cache_short", "repository_repo_path"))
1154 1154 return q.one().ui_value
1155 1155
1156 1156 @property
1157 1157 def repo_full_path(self):
1158 1158 p = [self.repo_path]
1159 1159 # we need to split the name by / since this is how we store the
1160 1160 # names in the database, but that eventually needs to be converted
1161 1161 # into a valid system path
1162 1162 p += self.repo_name.split(Repository.url_sep())
1163 1163 return os.path.join(*p)
1164 1164
1165 1165 @property
1166 1166 def cache_keys(self):
1167 1167 """
1168 1168 Returns associated cache keys for that repo
1169 1169 """
1170 1170 return CacheInvalidation.query() \
1171 1171 .filter(CacheInvalidation.cache_args == self.repo_name) \
1172 1172 .order_by(CacheInvalidation.cache_key) \
1173 1173 .all()
1174 1174
1175 1175 def get_new_name(self, repo_name):
1176 1176 """
1177 1177 returns new full repository name based on assigned group and new new
1178 1178
1179 1179 :param group_name:
1180 1180 """
1181 1181 path_prefix = self.group.full_path_splitted if self.group else []
1182 1182 return Repository.url_sep().join(path_prefix + [repo_name])
1183 1183
1184 1184 @property
1185 1185 def _ui(self):
1186 1186 """
1187 1187 Creates an db based ui object for this repository
1188 1188 """
1189 1189 from kallithea.lib.utils import make_ui
1190 1190 return make_ui()
1191 1191
1192 1192 @classmethod
1193 1193 def is_valid(cls, repo_name):
1194 1194 """
1195 1195 returns True if given repo name is a valid filesystem repository
1196 1196
1197 1197 :param cls:
1198 1198 :param repo_name:
1199 1199 """
1200 1200 from kallithea.lib.utils import is_valid_repo
1201 1201
1202 1202 return is_valid_repo(repo_name, cls.base_path())
1203 1203
1204 1204 def get_api_data(self, with_revision_names=False,
1205 1205 with_pullrequests=False):
1206 1206 """
1207 1207 Common function for generating repo api data.
1208 1208 Optionally, also return tags, branches, bookmarks and PRs.
1209 1209 """
1210 1210 repo = self
1211 1211 data = dict(
1212 1212 repo_id=repo.repo_id,
1213 1213 repo_name=repo.repo_name,
1214 1214 repo_type=repo.repo_type,
1215 1215 clone_uri=repo.clone_uri,
1216 1216 private=repo.private,
1217 1217 created_on=repo.created_on,
1218 1218 description=repo.description,
1219 1219 landing_rev=repo.landing_rev,
1220 1220 owner=repo.owner.username,
1221 1221 fork_of=repo.fork.repo_name if repo.fork else None,
1222 1222 enable_statistics=repo.enable_statistics,
1223 1223 enable_downloads=repo.enable_downloads,
1224 1224 last_changeset=repo.changeset_cache,
1225 1225 )
1226 1226 if with_revision_names:
1227 1227 scm_repo = repo.scm_instance_no_cache()
1228 1228 data.update(dict(
1229 1229 tags=scm_repo.tags,
1230 1230 branches=scm_repo.branches,
1231 1231 bookmarks=scm_repo.bookmarks,
1232 1232 ))
1233 1233 if with_pullrequests:
1234 1234 data['pull_requests'] = repo.pull_requests_other
1235 1235 rc_config = Setting.get_app_settings()
1236 1236 repository_fields = str2bool(rc_config.get('repository_fields'))
1237 1237 if repository_fields:
1238 1238 for f in self.extra_fields:
1239 1239 data[f.field_key_prefixed] = f.field_value
1240 1240
1241 1241 return data
1242 1242
1243 1243 @property
1244 1244 def last_db_change(self):
1245 1245 return self.updated_on
1246 1246
1247 1247 @property
1248 1248 def clone_uri_hidden(self):
1249 1249 clone_uri = self.clone_uri
1250 1250 if clone_uri:
1251 1251 import urlobject
1252 1252 url_obj = urlobject.URLObject(self.clone_uri)
1253 1253 if url_obj.password:
1254 1254 clone_uri = url_obj.with_password('*****')
1255 1255 return clone_uri
1256 1256
1257 1257 def clone_url(self, clone_uri_tmpl, with_id=False, username=None):
1258 1258 if '{repo}' not in clone_uri_tmpl and '_{repoid}' not in clone_uri_tmpl:
1259 1259 log.error("Configured clone_uri_tmpl %r has no '{repo}' or '_{repoid}' and cannot toggle to use repo id URLs", clone_uri_tmpl)
1260 1260 elif with_id:
1261 1261 clone_uri_tmpl = clone_uri_tmpl.replace('{repo}', '_{repoid}')
1262 1262 else:
1263 1263 clone_uri_tmpl = clone_uri_tmpl.replace('_{repoid}', '{repo}')
1264 1264
1265 1265 import kallithea.lib.helpers as h
1266 1266 prefix_url = h.canonical_url('home')
1267 1267
1268 1268 return get_clone_url(clone_uri_tmpl=clone_uri_tmpl,
1269 1269 prefix_url=prefix_url,
1270 1270 repo_name=self.repo_name,
1271 1271 repo_id=self.repo_id,
1272 1272 username=username)
1273 1273
1274 1274 def set_state(self, state):
1275 1275 self.repo_state = state
1276 1276
1277 1277 #==========================================================================
1278 1278 # SCM PROPERTIES
1279 1279 #==========================================================================
1280 1280
1281 1281 def get_changeset(self, rev=None):
1282 1282 return get_changeset_safe(self.scm_instance, rev)
1283 1283
1284 1284 def get_landing_changeset(self):
1285 1285 """
1286 1286 Returns landing changeset, or if that doesn't exist returns the tip
1287 1287 """
1288 1288 _rev_type, _rev = self.landing_rev
1289 1289 cs = self.get_changeset(_rev)
1290 1290 if isinstance(cs, EmptyChangeset):
1291 1291 return self.get_changeset()
1292 1292 return cs
1293 1293
1294 1294 def update_changeset_cache(self, cs_cache=None):
1295 1295 """
1296 1296 Update cache of last changeset for repository, keys should be::
1297 1297
1298 1298 short_id
1299 1299 raw_id
1300 1300 revision
1301 1301 message
1302 1302 date
1303 1303 author
1304 1304
1305 1305 :param cs_cache:
1306 1306 """
1307 1307 from kallithea.lib.vcs.backends.base import BaseChangeset
1308 1308 if cs_cache is None:
1309 1309 cs_cache = EmptyChangeset()
1310 1310 # use no-cache version here
1311 1311 scm_repo = self.scm_instance_no_cache()
1312 1312 if scm_repo:
1313 1313 cs_cache = scm_repo.get_changeset()
1314 1314
1315 1315 if isinstance(cs_cache, BaseChangeset):
1316 1316 cs_cache = cs_cache.__json__()
1317 1317
1318 1318 if (not self.changeset_cache or cs_cache['raw_id'] != self.changeset_cache['raw_id']):
1319 1319 _default = datetime.datetime.fromtimestamp(0)
1320 1320 last_change = cs_cache.get('date') or _default
1321 1321 log.debug('updated repo %s with new cs cache %s',
1322 1322 self.repo_name, cs_cache)
1323 1323 self.updated_on = last_change
1324 1324 self.changeset_cache = cs_cache
1325 1325 Session().commit()
1326 1326 else:
1327 1327 log.debug('changeset_cache for %s already up to date with %s',
1328 1328 self.repo_name, cs_cache['raw_id'])
1329 1329
1330 1330 @property
1331 1331 def tip(self):
1332 1332 return self.get_changeset('tip')
1333 1333
1334 1334 @property
1335 1335 def author(self):
1336 1336 return self.tip.author
1337 1337
1338 1338 @property
1339 1339 def last_change(self):
1340 1340 return self.scm_instance.last_change
1341 1341
1342 1342 def get_comments(self, revisions=None):
1343 1343 """
1344 1344 Returns comments for this repository grouped by revisions
1345 1345
1346 1346 :param revisions: filter query by revisions only
1347 1347 """
1348 1348 cmts = ChangesetComment.query() \
1349 1349 .filter(ChangesetComment.repo == self)
1350 1350 if revisions is not None:
1351 1351 if not revisions:
1352 1352 return {} # don't use sql 'in' on empty set
1353 1353 cmts = cmts.filter(ChangesetComment.revision.in_(revisions))
1354 1354 grouped = collections.defaultdict(list)
1355 1355 for cmt in cmts.all():
1356 1356 grouped[cmt.revision].append(cmt)
1357 1357 return grouped
1358 1358
1359 1359 def statuses(self, revisions):
1360 1360 """
1361 1361 Returns statuses for this repository.
1362 1362 PRs without any votes do _not_ show up as unreviewed.
1363 1363
1364 1364 :param revisions: list of revisions to get statuses for
1365 1365 """
1366 1366 if not revisions:
1367 1367 return {}
1368 1368
1369 1369 statuses = ChangesetStatus.query() \
1370 1370 .filter(ChangesetStatus.repo == self) \
1371 1371 .filter(ChangesetStatus.version == 0) \
1372 1372 .filter(ChangesetStatus.revision.in_(revisions))
1373 1373
1374 1374 grouped = {}
1375 1375 for stat in statuses.all():
1376 1376 pr_id = pr_nice_id = pr_repo = None
1377 1377 if stat.pull_request:
1378 1378 pr_id = stat.pull_request.pull_request_id
1379 1379 pr_nice_id = PullRequest.make_nice_id(pr_id)
1380 1380 pr_repo = stat.pull_request.other_repo.repo_name
1381 1381 grouped[stat.revision] = [str(stat.status), stat.status_lbl,
1382 1382 pr_id, pr_repo, pr_nice_id,
1383 1383 stat.author]
1384 1384 return grouped
1385 1385
1386 1386 def _repo_size(self):
1387 1387 from kallithea.lib import helpers as h
1388 1388 log.debug('calculating repository size...')
1389 1389 return h.format_byte_size(self.scm_instance.size)
1390 1390
1391 1391 #==========================================================================
1392 1392 # SCM CACHE INSTANCE
1393 1393 #==========================================================================
1394 1394
1395 1395 def set_invalidate(self):
1396 1396 """
1397 1397 Mark caches of this repo as invalid.
1398 1398 """
1399 1399 CacheInvalidation.set_invalidate(self.repo_name)
1400 1400
1401 1401 _scm_instance = None
1402 1402
1403 1403 @property
1404 1404 def scm_instance(self):
1405 1405 if self._scm_instance is None:
1406 1406 self._scm_instance = self.scm_instance_cached()
1407 1407 return self._scm_instance
1408 1408
1409 1409 def scm_instance_cached(self, valid_cache_keys=None):
1410 1410 @cache_region('long_term', 'scm_instance_cached')
1411 1411 def _c(repo_name): # repo_name is just for the cache key
1412 1412 log.debug('Creating new %s scm_instance and populating cache', repo_name)
1413 1413 return self.scm_instance_no_cache()
1414 1414 rn = self.repo_name
1415 1415
1416 1416 valid = CacheInvalidation.test_and_set_valid(rn, None, valid_cache_keys=valid_cache_keys)
1417 1417 if not valid:
1418 1418 log.debug('Cache for %s invalidated, getting new object', rn)
1419 1419 region_invalidate(_c, None, 'scm_instance_cached', rn)
1420 1420 else:
1421 1421 log.debug('Trying to get scm_instance of %s from cache', rn)
1422 1422 return _c(rn)
1423 1423
1424 1424 def scm_instance_no_cache(self):
1425 1425 repo_full_path = self.repo_full_path
1426 1426 alias = get_scm(repo_full_path)[0]
1427 1427 log.debug('Creating instance of %s repository from %s',
1428 1428 alias, self.repo_full_path)
1429 1429 backend = get_backend(alias)
1430 1430
1431 1431 if alias == 'hg':
1432 1432 repo = backend(repo_full_path, create=False,
1433 1433 baseui=self._ui)
1434 1434 else:
1435 1435 repo = backend(repo_full_path, create=False)
1436 1436
1437 1437 return repo
1438 1438
1439 1439 def __json__(self):
1440 1440 return dict(
1441 1441 repo_id=self.repo_id,
1442 1442 repo_name=self.repo_name,
1443 1443 landing_rev=self.landing_rev,
1444 1444 )
1445 1445
1446 1446
1447 1447 class RepoGroup(Base, BaseDbModel):
1448 1448 __tablename__ = 'groups'
1449 1449 __table_args__ = (
1450 1450 _table_args_default_dict,
1451 1451 )
1452 1452
1453 1453 SEP = ' &raquo; '
1454 1454
1455 1455 group_id = Column(Integer(), primary_key=True)
1456 1456 group_name = Column(Unicode(255), nullable=False, unique=True) # full path
1457 1457 parent_group_id = Column('group_parent_id', Integer(), ForeignKey('groups.group_id'), nullable=True)
1458 1458 group_description = Column(Unicode(10000), nullable=False)
1459 1459 owner_id = Column('user_id', Integer(), ForeignKey('users.user_id'), nullable=False)
1460 1460 created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
1461 1461
1462 1462 repo_group_to_perm = relationship('UserRepoGroupToPerm', cascade='all', order_by='UserRepoGroupToPerm.group_to_perm_id')
1463 1463 users_group_to_perm = relationship('UserGroupRepoGroupToPerm', cascade='all')
1464 1464 parent_group = relationship('RepoGroup', remote_side=group_id)
1465 1465 owner = relationship('User')
1466 1466
1467 1467 @classmethod
1468 1468 def query(cls, sorted=False):
1469 1469 """Add RepoGroup-specific helpers for common query constructs.
1470 1470
1471 1471 sorted: if True, apply the default ordering (name, case insensitive).
1472 1472 """
1473 1473 q = super(RepoGroup, cls).query()
1474 1474
1475 1475 if sorted:
1476 1476 q = q.order_by(sqlalchemy.func.lower(RepoGroup.group_name))
1477 1477
1478 1478 return q
1479 1479
1480 1480 def __init__(self, group_name='', parent_group=None):
1481 1481 self.group_name = group_name
1482 1482 self.parent_group = parent_group
1483 1483
1484 1484 def __repr__(self):
1485 1485 return "<%s %s: %s>" % (self.__class__.__name__,
1486 1486 self.group_id, self.group_name)
1487 1487
1488 1488 @classmethod
1489 1489 def _generate_choice(cls, repo_group):
1490 1490 """Return tuple with group_id and name as html literal"""
1491 1491 from webhelpers2.html import literal
1492 1492 if repo_group is None:
1493 1493 return (-1, '-- %s --' % _('top level'))
1494 1494 return repo_group.group_id, literal(cls.SEP.join(repo_group.full_path_splitted))
1495 1495
1496 1496 @classmethod
1497 1497 def groups_choices(cls, groups):
1498 1498 """Return tuples with group_id and name as html literal."""
1499 1499 return sorted((cls._generate_choice(g) for g in groups),
1500 1500 key=lambda c: c[1].split(cls.SEP))
1501 1501
1502 1502 @classmethod
1503 1503 def url_sep(cls):
1504 1504 return URL_SEP
1505 1505
1506 1506 @classmethod
1507 1507 def guess_instance(cls, value):
1508 1508 return super(RepoGroup, cls).guess_instance(value, RepoGroup.get_by_group_name)
1509 1509
1510 1510 @classmethod
1511 1511 def get_by_group_name(cls, group_name, cache=False, case_insensitive=False):
1512 1512 group_name = group_name.rstrip('/')
1513 1513 if case_insensitive:
1514 1514 gr = cls.query() \
1515 1515 .filter(sqlalchemy.func.lower(cls.group_name) == sqlalchemy.func.lower(group_name))
1516 1516 else:
1517 1517 gr = cls.query() \
1518 1518 .filter(cls.group_name == group_name)
1519 1519 if cache:
1520 1520 gr = gr.options(FromCache(
1521 1521 "sql_cache_short",
1522 1522 "get_group_%s" % _hash_key(group_name)
1523 1523 )
1524 1524 )
1525 1525 return gr.scalar()
1526 1526
1527 1527 @property
1528 1528 def parents(self):
1529 1529 groups = []
1530 1530 group = self.parent_group
1531 1531 while group is not None:
1532 1532 groups.append(group)
1533 1533 group = group.parent_group
1534 1534 assert group not in groups, group # avoid recursion on bad db content
1535 1535 groups.reverse()
1536 1536 return groups
1537 1537
1538 1538 @property
1539 1539 def children(self):
1540 1540 return RepoGroup.query().filter(RepoGroup.parent_group == self)
1541 1541
1542 1542 @property
1543 1543 def name(self):
1544 1544 return self.group_name.split(RepoGroup.url_sep())[-1]
1545 1545
1546 1546 @property
1547 1547 def full_path(self):
1548 1548 return self.group_name
1549 1549
1550 1550 @property
1551 1551 def full_path_splitted(self):
1552 1552 return self.group_name.split(RepoGroup.url_sep())
1553 1553
1554 1554 @property
1555 1555 def repositories(self):
1556 1556 return Repository.query(sorted=True).filter_by(group=self)
1557 1557
1558 1558 @property
1559 1559 def repositories_recursive_count(self):
1560 1560 cnt = self.repositories.count()
1561 1561
1562 1562 def children_count(group):
1563 1563 cnt = 0
1564 1564 for child in group.children:
1565 1565 cnt += child.repositories.count()
1566 1566 cnt += children_count(child)
1567 1567 return cnt
1568 1568
1569 1569 return cnt + children_count(self)
1570 1570
1571 1571 def _recursive_objects(self, include_repos=True):
1572 1572 all_ = []
1573 1573
1574 1574 def _get_members(root_gr):
1575 1575 if include_repos:
1576 1576 for r in root_gr.repositories:
1577 1577 all_.append(r)
1578 1578 childs = root_gr.children.all()
1579 1579 if childs:
1580 1580 for gr in childs:
1581 1581 all_.append(gr)
1582 1582 _get_members(gr)
1583 1583
1584 1584 _get_members(self)
1585 1585 return [self] + all_
1586 1586
1587 1587 def recursive_groups_and_repos(self):
1588 1588 """
1589 1589 Recursive return all groups, with repositories in those groups
1590 1590 """
1591 1591 return self._recursive_objects()
1592 1592
1593 1593 def recursive_groups(self):
1594 1594 """
1595 1595 Returns all children groups for this group including children of children
1596 1596 """
1597 1597 return self._recursive_objects(include_repos=False)
1598 1598
1599 1599 def get_new_name(self, group_name):
1600 1600 """
1601 1601 returns new full group name based on parent and new name
1602 1602
1603 1603 :param group_name:
1604 1604 """
1605 1605 path_prefix = (self.parent_group.full_path_splitted if
1606 1606 self.parent_group else [])
1607 1607 return RepoGroup.url_sep().join(path_prefix + [group_name])
1608 1608
1609 1609 def get_api_data(self):
1610 1610 """
1611 1611 Common function for generating api data
1612 1612
1613 1613 """
1614 1614 group = self
1615 1615 data = dict(
1616 1616 group_id=group.group_id,
1617 1617 group_name=group.group_name,
1618 1618 group_description=group.group_description,
1619 1619 parent_group=group.parent_group.group_name if group.parent_group else None,
1620 1620 repositories=[x.repo_name for x in group.repositories],
1621 1621 owner=group.owner.username
1622 1622 )
1623 1623 return data
1624 1624
1625 1625
1626 1626 class Permission(Base, BaseDbModel):
1627 1627 __tablename__ = 'permissions'
1628 1628 __table_args__ = (
1629 1629 Index('p_perm_name_idx', 'permission_name'),
1630 1630 _table_args_default_dict,
1631 1631 )
1632 1632
1633 1633 PERMS = (
1634 1634 ('hg.admin', _('Kallithea Administrator')),
1635 1635
1636 1636 ('repository.none', _('Default user has no access to new repositories')),
1637 1637 ('repository.read', _('Default user has read access to new repositories')),
1638 1638 ('repository.write', _('Default user has write access to new repositories')),
1639 1639 ('repository.admin', _('Default user has admin access to new repositories')),
1640 1640
1641 1641 ('group.none', _('Default user has no access to new repository groups')),
1642 1642 ('group.read', _('Default user has read access to new repository groups')),
1643 1643 ('group.write', _('Default user has write access to new repository groups')),
1644 1644 ('group.admin', _('Default user has admin access to new repository groups')),
1645 1645
1646 1646 ('usergroup.none', _('Default user has no access to new user groups')),
1647 1647 ('usergroup.read', _('Default user has read access to new user groups')),
1648 1648 ('usergroup.write', _('Default user has write access to new user groups')),
1649 1649 ('usergroup.admin', _('Default user has admin access to new user groups')),
1650 1650
1651 1651 ('hg.repogroup.create.false', _('Only admins can create repository groups')),
1652 1652 ('hg.repogroup.create.true', _('Non-admins can create repository groups')),
1653 1653
1654 1654 ('hg.usergroup.create.false', _('Only admins can create user groups')),
1655 1655 ('hg.usergroup.create.true', _('Non-admins can create user groups')),
1656 1656
1657 1657 ('hg.create.none', _('Only admins can create top level repositories')),
1658 1658 ('hg.create.repository', _('Non-admins can create top level repositories')),
1659 1659
1660 1660 ('hg.create.write_on_repogroup.true', _('Repository creation enabled with write permission to a repository group')),
1661 1661 ('hg.create.write_on_repogroup.false', _('Repository creation disabled with write permission to a repository group')),
1662 1662
1663 1663 ('hg.fork.none', _('Only admins can fork repositories')),
1664 1664 ('hg.fork.repository', _('Non-admins can fork repositories')),
1665 1665
1666 1666 ('hg.register.none', _('Registration disabled')),
1667 1667 ('hg.register.manual_activate', _('User registration with manual account activation')),
1668 1668 ('hg.register.auto_activate', _('User registration with automatic account activation')),
1669 1669
1670 1670 ('hg.extern_activate.manual', _('Manual activation of external account')),
1671 1671 ('hg.extern_activate.auto', _('Automatic activation of external account')),
1672 1672 )
1673 1673
1674 1674 # definition of system default permissions for DEFAULT user
1675 1675 DEFAULT_USER_PERMISSIONS = (
1676 1676 'repository.read',
1677 1677 'group.read',
1678 1678 'usergroup.read',
1679 1679 'hg.create.repository',
1680 1680 'hg.create.write_on_repogroup.true',
1681 1681 'hg.fork.repository',
1682 1682 'hg.register.manual_activate',
1683 1683 'hg.extern_activate.auto',
1684 1684 )
1685 1685
1686 1686 # defines which permissions are more important higher the more important
1687 1687 # Weight defines which permissions are more important.
1688 1688 # The higher number the more important.
1689 1689 PERM_WEIGHTS = {
1690 1690 'repository.none': 0,
1691 1691 'repository.read': 1,
1692 1692 'repository.write': 3,
1693 1693 'repository.admin': 4,
1694 1694
1695 1695 'group.none': 0,
1696 1696 'group.read': 1,
1697 1697 'group.write': 3,
1698 1698 'group.admin': 4,
1699 1699
1700 1700 'usergroup.none': 0,
1701 1701 'usergroup.read': 1,
1702 1702 'usergroup.write': 3,
1703 1703 'usergroup.admin': 4,
1704 1704
1705 1705 'hg.repogroup.create.false': 0,
1706 1706 'hg.repogroup.create.true': 1,
1707 1707
1708 1708 'hg.usergroup.create.false': 0,
1709 1709 'hg.usergroup.create.true': 1,
1710 1710
1711 1711 'hg.fork.none': 0,
1712 1712 'hg.fork.repository': 1,
1713 1713
1714 1714 'hg.create.none': 0,
1715 1715 'hg.create.repository': 1,
1716 1716
1717 1717 'hg.create.write_on_repogroup.false': 0,
1718 1718 'hg.create.write_on_repogroup.true': 1,
1719 1719
1720 1720 'hg.register.none': 0,
1721 1721 'hg.register.manual_activate': 1,
1722 1722 'hg.register.auto_activate': 2,
1723 1723
1724 1724 'hg.extern_activate.manual': 0,
1725 1725 'hg.extern_activate.auto': 1,
1726 1726 }
1727 1727
1728 1728 permission_id = Column(Integer(), primary_key=True)
1729 1729 permission_name = Column(String(255), nullable=False)
1730 1730
1731 1731 def __repr__(self):
1732 1732 return "<%s %s: %r>" % (
1733 1733 self.__class__.__name__, self.permission_id, self.permission_name
1734 1734 )
1735 1735
1736 1736 @classmethod
1737 1737 def guess_instance(cls, value):
1738 1738 return super(Permission, cls).guess_instance(value, Permission.get_by_key)
1739 1739
1740 1740 @classmethod
1741 1741 def get_by_key(cls, key):
1742 1742 return cls.query().filter(cls.permission_name == key).scalar()
1743 1743
1744 1744 @classmethod
1745 1745 def get_default_perms(cls, default_user_id):
1746 1746 q = Session().query(UserRepoToPerm, Repository, cls) \
1747 1747 .join((Repository, UserRepoToPerm.repository_id == Repository.repo_id)) \
1748 1748 .join((cls, UserRepoToPerm.permission_id == cls.permission_id)) \
1749 1749 .filter(UserRepoToPerm.user_id == default_user_id)
1750 1750
1751 1751 return q.all()
1752 1752
1753 1753 @classmethod
1754 1754 def get_default_group_perms(cls, default_user_id):
1755 1755 q = Session().query(UserRepoGroupToPerm, RepoGroup, cls) \
1756 1756 .join((RepoGroup, UserRepoGroupToPerm.group_id == RepoGroup.group_id)) \
1757 1757 .join((cls, UserRepoGroupToPerm.permission_id == cls.permission_id)) \
1758 1758 .filter(UserRepoGroupToPerm.user_id == default_user_id)
1759 1759
1760 1760 return q.all()
1761 1761
1762 1762 @classmethod
1763 1763 def get_default_user_group_perms(cls, default_user_id):
1764 1764 q = Session().query(UserUserGroupToPerm, UserGroup, cls) \
1765 1765 .join((UserGroup, UserUserGroupToPerm.user_group_id == UserGroup.users_group_id)) \
1766 1766 .join((cls, UserUserGroupToPerm.permission_id == cls.permission_id)) \
1767 1767 .filter(UserUserGroupToPerm.user_id == default_user_id)
1768 1768
1769 1769 return q.all()
1770 1770
1771 1771
1772 1772 class UserRepoToPerm(Base, BaseDbModel):
1773 1773 __tablename__ = 'repo_to_perm'
1774 1774 __table_args__ = (
1775 1775 UniqueConstraint('user_id', 'repository_id', 'permission_id'),
1776 1776 _table_args_default_dict,
1777 1777 )
1778 1778
1779 1779 repo_to_perm_id = Column(Integer(), primary_key=True)
1780 1780 user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)
1781 1781 permission_id = Column(Integer(), ForeignKey('permissions.permission_id'), nullable=False)
1782 1782 repository_id = Column(Integer(), ForeignKey('repositories.repo_id'), nullable=False)
1783 1783
1784 1784 user = relationship('User')
1785 1785 repository = relationship('Repository')
1786 1786 permission = relationship('Permission')
1787 1787
1788 1788 @classmethod
1789 1789 def create(cls, user, repository, permission):
1790 1790 n = cls()
1791 1791 n.user = user
1792 1792 n.repository = repository
1793 1793 n.permission = permission
1794 1794 Session().add(n)
1795 1795 return n
1796 1796
1797 1797 def __repr__(self):
1798 1798 return '<%s %s at %s: %s>' % (
1799 1799 self.__class__.__name__, self.user, self.repository, self.permission)
1800 1800
1801 1801
1802 1802 class UserUserGroupToPerm(Base, BaseDbModel):
1803 1803 __tablename__ = 'user_user_group_to_perm'
1804 1804 __table_args__ = (
1805 1805 UniqueConstraint('user_id', 'user_group_id', 'permission_id'),
1806 1806 _table_args_default_dict,
1807 1807 )
1808 1808
1809 1809 user_user_group_to_perm_id = Column(Integer(), primary_key=True)
1810 1810 user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)
1811 1811 permission_id = Column(Integer(), ForeignKey('permissions.permission_id'), nullable=False)
1812 1812 user_group_id = Column(Integer(), ForeignKey('users_groups.users_group_id'), nullable=False)
1813 1813
1814 1814 user = relationship('User')
1815 1815 user_group = relationship('UserGroup')
1816 1816 permission = relationship('Permission')
1817 1817
1818 1818 @classmethod
1819 1819 def create(cls, user, user_group, permission):
1820 1820 n = cls()
1821 1821 n.user = user
1822 1822 n.user_group = user_group
1823 1823 n.permission = permission
1824 1824 Session().add(n)
1825 1825 return n
1826 1826
1827 1827 def __repr__(self):
1828 1828 return '<%s %s at %s: %s>' % (
1829 1829 self.__class__.__name__, self.user, self.user_group, self.permission)
1830 1830
1831 1831
1832 1832 class UserToPerm(Base, BaseDbModel):
1833 1833 __tablename__ = 'user_to_perm'
1834 1834 __table_args__ = (
1835 1835 UniqueConstraint('user_id', 'permission_id'),
1836 1836 _table_args_default_dict,
1837 1837 )
1838 1838
1839 1839 user_to_perm_id = Column(Integer(), primary_key=True)
1840 1840 user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)
1841 1841 permission_id = Column(Integer(), ForeignKey('permissions.permission_id'), nullable=False)
1842 1842
1843 1843 user = relationship('User')
1844 1844 permission = relationship('Permission')
1845 1845
1846 1846 def __repr__(self):
1847 1847 return '<%s %s: %s>' % (
1848 1848 self.__class__.__name__, self.user, self.permission)
1849 1849
1850 1850
1851 1851 class UserGroupRepoToPerm(Base, BaseDbModel):
1852 1852 __tablename__ = 'users_group_repo_to_perm'
1853 1853 __table_args__ = (
1854 1854 UniqueConstraint('repository_id', 'users_group_id', 'permission_id'),
1855 1855 _table_args_default_dict,
1856 1856 )
1857 1857
1858 1858 users_group_to_perm_id = Column(Integer(), primary_key=True)
1859 1859 users_group_id = Column(Integer(), ForeignKey('users_groups.users_group_id'), nullable=False)
1860 1860 permission_id = Column(Integer(), ForeignKey('permissions.permission_id'), nullable=False)
1861 1861 repository_id = Column(Integer(), ForeignKey('repositories.repo_id'), nullable=False)
1862 1862
1863 1863 users_group = relationship('UserGroup')
1864 1864 permission = relationship('Permission')
1865 1865 repository = relationship('Repository')
1866 1866
1867 1867 @classmethod
1868 1868 def create(cls, users_group, repository, permission):
1869 1869 n = cls()
1870 1870 n.users_group = users_group
1871 1871 n.repository = repository
1872 1872 n.permission = permission
1873 1873 Session().add(n)
1874 1874 return n
1875 1875
1876 1876 def __repr__(self):
1877 1877 return '<%s %s at %s: %s>' % (
1878 1878 self.__class__.__name__, self.users_group, self.repository, self.permission)
1879 1879
1880 1880
1881 1881 class UserGroupUserGroupToPerm(Base, BaseDbModel):
1882 1882 __tablename__ = 'user_group_user_group_to_perm'
1883 1883 __table_args__ = (
1884 1884 UniqueConstraint('target_user_group_id', 'user_group_id', 'permission_id'),
1885 1885 _table_args_default_dict,
1886 1886 )
1887 1887
1888 1888 user_group_user_group_to_perm_id = Column(Integer(), primary_key=True)
1889 1889 target_user_group_id = Column(Integer(), ForeignKey('users_groups.users_group_id'), nullable=False)
1890 1890 permission_id = Column(Integer(), ForeignKey('permissions.permission_id'), nullable=False)
1891 1891 user_group_id = Column(Integer(), ForeignKey('users_groups.users_group_id'), nullable=False)
1892 1892
1893 1893 target_user_group = relationship('UserGroup', primaryjoin='UserGroupUserGroupToPerm.target_user_group_id==UserGroup.users_group_id')
1894 1894 user_group = relationship('UserGroup', primaryjoin='UserGroupUserGroupToPerm.user_group_id==UserGroup.users_group_id')
1895 1895 permission = relationship('Permission')
1896 1896
1897 1897 @classmethod
1898 1898 def create(cls, target_user_group, user_group, permission):
1899 1899 n = cls()
1900 1900 n.target_user_group = target_user_group
1901 1901 n.user_group = user_group
1902 1902 n.permission = permission
1903 1903 Session().add(n)
1904 1904 return n
1905 1905
1906 1906 def __repr__(self):
1907 1907 return '<%s %s at %s: %s>' % (
1908 1908 self.__class__.__name__, self.user_group, self.target_user_group, self.permission)
1909 1909
1910 1910
1911 1911 class UserGroupToPerm(Base, BaseDbModel):
1912 1912 __tablename__ = 'users_group_to_perm'
1913 1913 __table_args__ = (
1914 1914 UniqueConstraint('users_group_id', 'permission_id',),
1915 1915 _table_args_default_dict,
1916 1916 )
1917 1917
1918 1918 users_group_to_perm_id = Column(Integer(), primary_key=True)
1919 1919 users_group_id = Column(Integer(), ForeignKey('users_groups.users_group_id'), nullable=False)
1920 1920 permission_id = Column(Integer(), ForeignKey('permissions.permission_id'), nullable=False)
1921 1921
1922 1922 users_group = relationship('UserGroup')
1923 1923 permission = relationship('Permission')
1924 1924
1925 1925
1926 1926 class UserRepoGroupToPerm(Base, BaseDbModel):
1927 1927 __tablename__ = 'user_repo_group_to_perm'
1928 1928 __table_args__ = (
1929 1929 UniqueConstraint('user_id', 'group_id', 'permission_id'),
1930 1930 _table_args_default_dict,
1931 1931 )
1932 1932
1933 1933 group_to_perm_id = Column(Integer(), primary_key=True)
1934 1934 user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)
1935 1935 group_id = Column(Integer(), ForeignKey('groups.group_id'), nullable=False)
1936 1936 permission_id = Column(Integer(), ForeignKey('permissions.permission_id'), nullable=False)
1937 1937
1938 1938 user = relationship('User')
1939 1939 group = relationship('RepoGroup')
1940 1940 permission = relationship('Permission')
1941 1941
1942 1942 @classmethod
1943 1943 def create(cls, user, repository_group, permission):
1944 1944 n = cls()
1945 1945 n.user = user
1946 1946 n.group = repository_group
1947 1947 n.permission = permission
1948 1948 Session().add(n)
1949 1949 return n
1950 1950
1951 1951
1952 1952 class UserGroupRepoGroupToPerm(Base, BaseDbModel):
1953 1953 __tablename__ = 'users_group_repo_group_to_perm'
1954 1954 __table_args__ = (
1955 1955 UniqueConstraint('users_group_id', 'group_id'),
1956 1956 _table_args_default_dict,
1957 1957 )
1958 1958
1959 1959 users_group_repo_group_to_perm_id = Column(Integer(), primary_key=True)
1960 1960 users_group_id = Column(Integer(), ForeignKey('users_groups.users_group_id'), nullable=False)
1961 1961 group_id = Column(Integer(), ForeignKey('groups.group_id'), nullable=False)
1962 1962 permission_id = Column(Integer(), ForeignKey('permissions.permission_id'), nullable=False)
1963 1963
1964 1964 users_group = relationship('UserGroup')
1965 1965 permission = relationship('Permission')
1966 1966 group = relationship('RepoGroup')
1967 1967
1968 1968 @classmethod
1969 1969 def create(cls, user_group, repository_group, permission):
1970 1970 n = cls()
1971 1971 n.users_group = user_group
1972 1972 n.group = repository_group
1973 1973 n.permission = permission
1974 1974 Session().add(n)
1975 1975 return n
1976 1976
1977 1977
1978 1978 class Statistics(Base, BaseDbModel):
1979 1979 __tablename__ = 'statistics'
1980 1980 __table_args__ = (
1981 1981 _table_args_default_dict,
1982 1982 )
1983 1983
1984 1984 stat_id = Column(Integer(), primary_key=True)
1985 1985 repository_id = Column(Integer(), ForeignKey('repositories.repo_id'), nullable=False, unique=True)
1986 1986 stat_on_revision = Column(Integer(), nullable=False)
1987 1987 commit_activity = Column(LargeBinary(1000000), nullable=False) # JSON data
1988 1988 commit_activity_combined = Column(LargeBinary(), nullable=False) # JSON data
1989 1989 languages = Column(LargeBinary(1000000), nullable=False) # JSON data
1990 1990
1991 1991 repository = relationship('Repository', single_parent=True)
1992 1992
1993 1993
1994 1994 class UserFollowing(Base, BaseDbModel):
1995 1995 __tablename__ = 'user_followings'
1996 1996 __table_args__ = (
1997 1997 UniqueConstraint('user_id', 'follows_repository_id', name='uq_user_followings_user_repo'),
1998 1998 UniqueConstraint('user_id', 'follows_user_id', name='uq_user_followings_user_user'),
1999 1999 _table_args_default_dict,
2000 2000 )
2001 2001
2002 2002 user_following_id = Column(Integer(), primary_key=True)
2003 2003 user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)
2004 2004 follows_repository_id = Column(Integer(), ForeignKey('repositories.repo_id'), nullable=True)
2005 2005 follows_user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=True)
2006 2006 follows_from = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
2007 2007
2008 2008 user = relationship('User', primaryjoin='User.user_id==UserFollowing.user_id')
2009 2009
2010 2010 follows_user = relationship('User', primaryjoin='User.user_id==UserFollowing.follows_user_id')
2011 2011 follows_repository = relationship('Repository', order_by=lambda: sqlalchemy.func.lower(Repository.repo_name))
2012 2012
2013 2013 @classmethod
2014 2014 def get_repo_followers(cls, repo_id):
2015 2015 return cls.query().filter(cls.follows_repository_id == repo_id)
2016 2016
2017 2017
2018 2018 class CacheInvalidation(Base, BaseDbModel):
2019 2019 __tablename__ = 'cache_invalidation'
2020 2020 __table_args__ = (
2021 2021 Index('key_idx', 'cache_key'),
2022 2022 _table_args_default_dict,
2023 2023 )
2024 2024
2025 2025 # cache_id, not used
2026 2026 cache_id = Column(Integer(), primary_key=True)
2027 2027 # cache_key as created by _get_cache_key
2028 2028 cache_key = Column(Unicode(255), nullable=False, unique=True)
2029 2029 # cache_args is a repo_name
2030 2030 cache_args = Column(Unicode(255), nullable=False)
2031 2031 # instance sets cache_active True when it is caching, other instances set
2032 2032 # cache_active to False to indicate that this cache is invalid
2033 2033 cache_active = Column(Boolean(), nullable=False, default=False)
2034 2034
2035 2035 def __init__(self, cache_key, repo_name=''):
2036 2036 self.cache_key = cache_key
2037 2037 self.cache_args = repo_name
2038 2038 self.cache_active = False
2039 2039
2040 2040 def __repr__(self):
2041 2041 return "<%s %s: %s=%s" % (
2042 2042 self.__class__.__name__,
2043 2043 self.cache_id, self.cache_key, self.cache_active)
2044 2044
2045 2045 def _cache_key_partition(self):
2046 2046 prefix, repo_name, suffix = self.cache_key.partition(self.cache_args)
2047 2047 return prefix, repo_name, suffix
2048 2048
2049 2049 def get_prefix(self):
2050 2050 """
2051 2051 get prefix that might have been used in _get_cache_key to
2052 2052 generate self.cache_key. Only used for informational purposes
2053 2053 in repo_edit.html.
2054 2054 """
2055 2055 # prefix, repo_name, suffix
2056 2056 return self._cache_key_partition()[0]
2057 2057
2058 2058 def get_suffix(self):
2059 2059 """
2060 2060 get suffix that might have been used in _get_cache_key to
2061 2061 generate self.cache_key. Only used for informational purposes
2062 2062 in repo_edit.html.
2063 2063 """
2064 2064 # prefix, repo_name, suffix
2065 2065 return self._cache_key_partition()[2]
2066 2066
2067 2067 @classmethod
2068 2068 def clear_cache(cls):
2069 2069 """
2070 2070 Delete all cache keys from database.
2071 2071 Should only be run when all instances are down and all entries thus stale.
2072 2072 """
2073 2073 cls.query().delete()
2074 2074 Session().commit()
2075 2075
2076 2076 @classmethod
2077 2077 def _get_cache_key(cls, key):
2078 2078 """
2079 2079 Wrapper for generating a unique cache key for this instance and "key".
2080 2080 key must / will start with a repo_name which will be stored in .cache_args .
2081 2081 """
2082 2082 prefix = kallithea.CONFIG.get('instance_id', '')
2083 2083 return "%s%s" % (prefix, key)
2084 2084
2085 2085 @classmethod
2086 2086 def set_invalidate(cls, repo_name):
2087 2087 """
2088 2088 Mark all caches of a repo as invalid in the database.
2089 2089 """
2090 2090 inv_objs = Session().query(cls).filter(cls.cache_args == repo_name).all()
2091 2091 log.debug('for repo %s got %s invalidation objects',
2092 2092 repo_name, inv_objs)
2093 2093
2094 2094 for inv_obj in inv_objs:
2095 2095 log.debug('marking %s key for invalidation based on repo_name=%s',
2096 2096 inv_obj, repo_name)
2097 2097 Session().delete(inv_obj)
2098 2098 Session().commit()
2099 2099
2100 2100 @classmethod
2101 2101 def test_and_set_valid(cls, repo_name, kind, valid_cache_keys=None):
2102 2102 """
2103 2103 Mark this cache key as active and currently cached.
2104 2104 Return True if the existing cache registration still was valid.
2105 2105 Return False to indicate that it had been invalidated and caches should be refreshed.
2106 2106 """
2107 2107
2108 2108 key = (repo_name + '_' + kind) if kind else repo_name
2109 2109 cache_key = cls._get_cache_key(key)
2110 2110
2111 2111 if valid_cache_keys and cache_key in valid_cache_keys:
2112 2112 return True
2113 2113
2114 2114 inv_obj = cls.query().filter(cls.cache_key == cache_key).scalar()
2115 2115 if inv_obj is None:
2116 2116 inv_obj = cls(cache_key, repo_name)
2117 2117 Session().add(inv_obj)
2118 2118 elif inv_obj.cache_active:
2119 2119 return True
2120 2120 inv_obj.cache_active = True
2121 2121 try:
2122 2122 Session().commit()
2123 2123 except sqlalchemy.exc.IntegrityError:
2124 2124 log.error('commit of CacheInvalidation failed - retrying')
2125 2125 Session().rollback()
2126 2126 inv_obj = cls.query().filter(cls.cache_key == cache_key).scalar()
2127 2127 if inv_obj is None:
2128 2128 log.error('failed to create CacheInvalidation entry')
2129 2129 # TODO: fail badly?
2130 2130 # else: TOCTOU - another thread added the key at the same time; no further action required
2131 2131 return False
2132 2132
2133 2133 @classmethod
2134 2134 def get_valid_cache_keys(cls):
2135 2135 """
2136 2136 Return opaque object with information of which caches still are valid
2137 2137 and can be used without checking for invalidation.
2138 2138 """
2139 2139 return set(inv_obj.cache_key for inv_obj in cls.query().filter(cls.cache_active).all())
2140 2140
2141 2141
2142 2142 class ChangesetComment(Base, BaseDbModel):
2143 2143 __tablename__ = 'changeset_comments'
2144 2144 __table_args__ = (
2145 2145 Index('cc_revision_idx', 'revision'),
2146 2146 Index('cc_pull_request_id_idx', 'pull_request_id'),
2147 2147 _table_args_default_dict,
2148 2148 )
2149 2149
2150 2150 comment_id = Column(Integer(), primary_key=True)
2151 2151 repo_id = Column(Integer(), ForeignKey('repositories.repo_id'), nullable=False)
2152 2152 revision = Column(String(40), nullable=True)
2153 2153 pull_request_id = Column(Integer(), ForeignKey('pull_requests.pull_request_id'), nullable=True)
2154 2154 line_no = Column(Unicode(10), nullable=True)
2155 2155 f_path = Column(Unicode(1000), nullable=True)
2156 2156 author_id = Column('user_id', Integer(), ForeignKey('users.user_id'), nullable=False)
2157 2157 text = Column(UnicodeText(), nullable=False)
2158 2158 created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
2159 2159 modified_at = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
2160 2160
2161 2161 author = relationship('User')
2162 2162 repo = relationship('Repository')
2163 2163 # status_change is frequently used directly in templates - make it a lazy
2164 2164 # join to avoid fetching each related ChangesetStatus on demand.
2165 2165 # There will only be one ChangesetStatus referencing each comment so the join will not explode.
2166 2166 status_change = relationship('ChangesetStatus',
2167 2167 cascade="all, delete-orphan", lazy='joined')
2168 2168 pull_request = relationship('PullRequest')
2169 2169
2170 2170 def url(self):
2171 2171 anchor = "comment-%s" % self.comment_id
2172 2172 import kallithea.lib.helpers as h
2173 2173 if self.revision:
2174 2174 return h.url('changeset_home', repo_name=self.repo.repo_name, revision=self.revision, anchor=anchor)
2175 2175 elif self.pull_request_id is not None:
2176 2176 return self.pull_request.url(anchor=anchor)
2177 2177
2178 2178 def __json__(self):
2179 2179 return dict(
2180 2180 comment_id=self.comment_id,
2181 2181 username=self.author.username,
2182 2182 text=self.text,
2183 2183 )
2184 2184
2185 2185 def deletable(self):
2186 2186 return self.created_on > datetime.datetime.now() - datetime.timedelta(minutes=5)
2187 2187
2188 2188
2189 2189 class ChangesetStatus(Base, BaseDbModel):
2190 2190 __tablename__ = 'changeset_statuses'
2191 2191 __table_args__ = (
2192 2192 Index('cs_revision_idx', 'revision'),
2193 2193 Index('cs_version_idx', 'version'),
2194 2194 Index('cs_pull_request_id_idx', 'pull_request_id'),
2195 2195 Index('cs_changeset_comment_id_idx', 'changeset_comment_id'),
2196 2196 Index('cs_pull_request_id_user_id_version_idx', 'pull_request_id', 'user_id', 'version'),
2197 2197 Index('cs_repo_id_pull_request_id_idx', 'repo_id', 'pull_request_id'),
2198 2198 UniqueConstraint('repo_id', 'revision', 'version'),
2199 2199 _table_args_default_dict,
2200 2200 )
2201 2201
2202 2202 STATUS_NOT_REVIEWED = DEFAULT = 'not_reviewed'
2203 2203 STATUS_APPROVED = 'approved'
2204 2204 STATUS_REJECTED = 'rejected' # is shown as "Not approved" - TODO: change database content / scheme
2205 2205 STATUS_UNDER_REVIEW = 'under_review'
2206 2206
2207 2207 STATUSES = [
2208 2208 (STATUS_NOT_REVIEWED, _("Not reviewed")), # (no icon) and default
2209 2209 (STATUS_UNDER_REVIEW, _("Under review")),
2210 2210 (STATUS_REJECTED, _("Not approved")),
2211 2211 (STATUS_APPROVED, _("Approved")),
2212 2212 ]
2213 2213 STATUSES_DICT = dict(STATUSES)
2214 2214
2215 2215 changeset_status_id = Column(Integer(), primary_key=True)
2216 2216 repo_id = Column(Integer(), ForeignKey('repositories.repo_id'), nullable=False)
2217 2217 user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)
2218 2218 revision = Column(String(40), nullable=True)
2219 2219 status = Column(String(128), nullable=False, default=DEFAULT)
2220 2220 comment_id = Column('changeset_comment_id', Integer(), ForeignKey('changeset_comments.comment_id'), nullable=False)
2221 2221 modified_at = Column(DateTime(), nullable=False, default=datetime.datetime.now)
2222 2222 version = Column(Integer(), nullable=False, default=0)
2223 2223 pull_request_id = Column(Integer(), ForeignKey('pull_requests.pull_request_id'), nullable=True)
2224 2224
2225 2225 author = relationship('User')
2226 2226 repo = relationship('Repository')
2227 2227 comment = relationship('ChangesetComment')
2228 2228 pull_request = relationship('PullRequest')
2229 2229
2230 2230 def __repr__(self):
2231 2231 return "<%s %r by %r>" % (
2232 2232 self.__class__.__name__,
2233 2233 self.status, self.author
2234 2234 )
2235 2235
2236 2236 @classmethod
2237 2237 def get_status_lbl(cls, value):
2238 2238 return cls.STATUSES_DICT.get(value)
2239 2239
2240 2240 @property
2241 2241 def status_lbl(self):
2242 2242 return ChangesetStatus.get_status_lbl(self.status)
2243 2243
2244 2244 def __json__(self):
2245 2245 return dict(
2246 2246 status=self.status,
2247 2247 modified_at=self.modified_at.replace(microsecond=0),
2248 2248 reviewer=self.author.username,
2249 2249 )
2250 2250
2251 2251
2252 2252 class PullRequest(Base, BaseDbModel):
2253 2253 __tablename__ = 'pull_requests'
2254 2254 __table_args__ = (
2255 2255 Index('pr_org_repo_id_idx', 'org_repo_id'),
2256 2256 Index('pr_other_repo_id_idx', 'other_repo_id'),
2257 2257 _table_args_default_dict,
2258 2258 )
2259 2259
2260 2260 # values for .status
2261 2261 STATUS_NEW = 'new'
2262 2262 STATUS_CLOSED = 'closed'
2263 2263
2264 2264 pull_request_id = Column(Integer(), primary_key=True)
2265 2265 title = Column(Unicode(255), nullable=False)
2266 2266 description = Column(UnicodeText(), nullable=False)
2267 2267 status = Column(Unicode(255), nullable=False, default=STATUS_NEW) # only for closedness, not approve/reject/etc
2268 2268 created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
2269 2269 updated_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
2270 2270 owner_id = Column('user_id', Integer(), ForeignKey('users.user_id'), nullable=False)
2271 2271 _revisions = Column('revisions', UnicodeText(), nullable=False)
2272 2272 org_repo_id = Column(Integer(), ForeignKey('repositories.repo_id'), nullable=False)
2273 2273 org_ref = Column(Unicode(255), nullable=False)
2274 2274 other_repo_id = Column(Integer(), ForeignKey('repositories.repo_id'), nullable=False)
2275 2275 other_ref = Column(Unicode(255), nullable=False)
2276 2276
2277 2277 @hybrid_property
2278 2278 def revisions(self):
2279 2279 return self._revisions.split(':')
2280 2280
2281 2281 @revisions.setter
2282 2282 def revisions(self, val):
2283 2283 self._revisions = ':'.join(val)
2284 2284
2285 2285 @property
2286 2286 def org_ref_parts(self):
2287 2287 return self.org_ref.split(':')
2288 2288
2289 2289 @property
2290 2290 def other_ref_parts(self):
2291 2291 return self.other_ref.split(':')
2292 2292
2293 2293 owner = relationship('User')
2294 2294 reviewers = relationship('PullRequestReviewer',
2295 2295 cascade="all, delete-orphan")
2296 2296 org_repo = relationship('Repository', primaryjoin='PullRequest.org_repo_id==Repository.repo_id')
2297 2297 other_repo = relationship('Repository', primaryjoin='PullRequest.other_repo_id==Repository.repo_id')
2298 2298 statuses = relationship('ChangesetStatus', order_by='ChangesetStatus.changeset_status_id')
2299 2299 comments = relationship('ChangesetComment', order_by='ChangesetComment.comment_id',
2300 2300 cascade="all, delete-orphan")
2301 2301
2302 2302 @classmethod
2303 2303 def query(cls, reviewer_id=None, include_closed=True, sorted=False):
2304 2304 """Add PullRequest-specific helpers for common query constructs.
2305 2305
2306 2306 reviewer_id: only PRs with the specified user added as reviewer.
2307 2307
2308 2308 include_closed: if False, do not include closed PRs.
2309 2309
2310 2310 sorted: if True, apply the default ordering (newest first).
2311 2311 """
2312 2312 q = super(PullRequest, cls).query()
2313 2313
2314 2314 if reviewer_id is not None:
2315 2315 q = q.join(PullRequestReviewer).filter(PullRequestReviewer.user_id == reviewer_id)
2316 2316
2317 2317 if not include_closed:
2318 2318 q = q.filter(PullRequest.status != PullRequest.STATUS_CLOSED)
2319 2319
2320 2320 if sorted:
2321 2321 q = q.order_by(PullRequest.created_on.desc())
2322 2322
2323 2323 return q
2324 2324
2325 2325 def get_reviewer_users(self):
2326 2326 """Like .reviewers, but actually returning the users"""
2327 2327 return User.query() \
2328 2328 .join(PullRequestReviewer) \
2329 2329 .filter(PullRequestReviewer.pull_request == self) \
2330 2330 .order_by(PullRequestReviewer.pull_request_reviewers_id) \
2331 2331 .all()
2332 2332
2333 2333 def is_closed(self):
2334 2334 return self.status == self.STATUS_CLOSED
2335 2335
2336 2336 def user_review_status(self, user_id):
2337 2337 """Return the user's latest status votes on PR"""
2338 2338 # note: no filtering on repo - that would be redundant
2339 2339 status = ChangesetStatus.query() \
2340 2340 .filter(ChangesetStatus.pull_request == self) \
2341 2341 .filter(ChangesetStatus.user_id == user_id) \
2342 2342 .order_by(ChangesetStatus.version) \
2343 2343 .first()
2344 2344 return str(status.status) if status else ''
2345 2345
2346 2346 @classmethod
2347 2347 def make_nice_id(cls, pull_request_id):
2348 2348 '''Return pull request id nicely formatted for displaying'''
2349 2349 return '#%s' % pull_request_id
2350 2350
2351 2351 def nice_id(self):
2352 2352 '''Return the id of this pull request, nicely formatted for displaying'''
2353 2353 return self.make_nice_id(self.pull_request_id)
2354 2354
2355 2355 def get_api_data(self):
2356 2356 return self.__json__()
2357 2357
2358 2358 def __json__(self):
2359 2359 clone_uri_tmpl = kallithea.CONFIG.get('clone_uri_tmpl') or Repository.DEFAULT_CLONE_URI
2360 2360 return dict(
2361 2361 pull_request_id=self.pull_request_id,
2362 2362 url=self.url(),
2363 2363 reviewers=self.reviewers,
2364 2364 revisions=self.revisions,
2365 2365 owner=self.owner.username,
2366 2366 title=self.title,
2367 2367 description=self.description,
2368 2368 org_repo_url=self.org_repo.clone_url(clone_uri_tmpl=clone_uri_tmpl),
2369 2369 org_ref_parts=self.org_ref_parts,
2370 2370 other_ref_parts=self.other_ref_parts,
2371 2371 status=self.status,
2372 2372 comments=self.comments,
2373 2373 statuses=self.statuses,
2374 2374 )
2375 2375
2376 2376 def url(self, **kwargs):
2377 2377 canonical = kwargs.pop('canonical', None)
2378 2378 import kallithea.lib.helpers as h
2379 2379 b = self.org_ref_parts[1]
2380 2380 if b != self.other_ref_parts[1]:
2381 2381 s = '/_/' + b
2382 2382 else:
2383 2383 s = '/_/' + self.title
2384 2384 kwargs['extra'] = urlreadable(s)
2385 2385 if canonical:
2386 2386 return h.canonical_url('pullrequest_show', repo_name=self.other_repo.repo_name,
2387 2387 pull_request_id=self.pull_request_id, **kwargs)
2388 2388 return h.url('pullrequest_show', repo_name=self.other_repo.repo_name,
2389 2389 pull_request_id=self.pull_request_id, **kwargs)
2390 2390
2391 2391
2392 2392 class PullRequestReviewer(Base, BaseDbModel):
2393 2393 __tablename__ = 'pull_request_reviewers'
2394 2394 __table_args__ = (
2395 2395 Index('pull_request_reviewers_user_id_idx', 'user_id'),
2396 2396 _table_args_default_dict,
2397 2397 )
2398 2398
2399 2399 def __init__(self, user=None, pull_request=None):
2400 2400 self.user = user
2401 2401 self.pull_request = pull_request
2402 2402
2403 2403 pull_request_reviewers_id = Column('pull_requests_reviewers_id', Integer(), primary_key=True)
2404 2404 pull_request_id = Column(Integer(), ForeignKey('pull_requests.pull_request_id'), nullable=False)
2405 2405 user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)
2406 2406
2407 2407 user = relationship('User')
2408 2408 pull_request = relationship('PullRequest')
2409 2409
2410 2410 def __json__(self):
2411 2411 return dict(
2412 2412 username=self.user.username if self.user else None,
2413 2413 )
2414 2414
2415 2415
2416 2416 class Notification(object):
2417 2417 __tablename__ = 'notifications'
2418 2418
2419 2419 class UserNotification(object):
2420 2420 __tablename__ = 'user_to_notification'
2421 2421
2422 2422
2423 2423 class Gist(Base, BaseDbModel):
2424 2424 __tablename__ = 'gists'
2425 2425 __table_args__ = (
2426 2426 Index('g_gist_access_id_idx', 'gist_access_id'),
2427 2427 Index('g_created_on_idx', 'created_on'),
2428 2428 _table_args_default_dict,
2429 2429 )
2430 2430
2431 2431 GIST_PUBLIC = 'public'
2432 2432 GIST_PRIVATE = 'private'
2433 2433 DEFAULT_FILENAME = 'gistfile1.txt'
2434 2434
2435 2435 gist_id = Column(Integer(), primary_key=True)
2436 2436 gist_access_id = Column(Unicode(250), nullable=False)
2437 2437 gist_description = Column(UnicodeText(), nullable=False)
2438 2438 owner_id = Column('user_id', Integer(), ForeignKey('users.user_id'), nullable=False)
2439 2439 gist_expires = Column(Float(53), nullable=False)
2440 2440 gist_type = Column(Unicode(128), nullable=False)
2441 2441 created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
2442 2442 modified_at = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
2443 2443
2444 2444 owner = relationship('User')
2445 2445
2446 2446 @hybrid_property
2447 2447 def is_expired(self):
2448 2448 return (self.gist_expires != -1) & (time.time() > self.gist_expires)
2449 2449
2450 2450 def __repr__(self):
2451 2451 return "<%s %s %s>" % (
2452 2452 self.__class__.__name__,
2453 2453 self.gist_type, self.gist_access_id)
2454 2454
2455 2455 @classmethod
2456 2456 def guess_instance(cls, value):
2457 2457 return super(Gist, cls).guess_instance(value, Gist.get_by_access_id)
2458 2458
2459 2459 @classmethod
2460 2460 def get_or_404(cls, id_):
2461 2461 res = cls.query().filter(cls.gist_access_id == id_).scalar()
2462 2462 if res is None:
2463 2463 raise HTTPNotFound
2464 2464 return res
2465 2465
2466 2466 @classmethod
2467 2467 def get_by_access_id(cls, gist_access_id):
2468 2468 return cls.query().filter(cls.gist_access_id == gist_access_id).scalar()
2469 2469
2470 2470 def gist_url(self):
2471 2471 alias_url = kallithea.CONFIG.get('gist_alias_url')
2472 2472 if alias_url:
2473 2473 return alias_url.replace('{gistid}', self.gist_access_id)
2474 2474
2475 2475 import kallithea.lib.helpers as h
2476 2476 return h.canonical_url('gist', gist_id=self.gist_access_id)
2477 2477
2478 2478 @classmethod
2479 2479 def base_path(cls):
2480 2480 """
2481 2481 Returns base path where all gists are stored
2482 2482
2483 2483 :param cls:
2484 2484 """
2485 2485 from kallithea.model.gist import GIST_STORE_LOC
2486 2486 q = Session().query(Ui) \
2487 2487 .filter(Ui.ui_key == URL_SEP)
2488 2488 q = q.options(FromCache("sql_cache_short", "repository_repo_path"))
2489 2489 return os.path.join(q.one().ui_value, GIST_STORE_LOC)
2490 2490
2491 2491 def get_api_data(self):
2492 2492 """
2493 2493 Common function for generating gist related data for API
2494 2494 """
2495 2495 gist = self
2496 2496 data = dict(
2497 2497 gist_id=gist.gist_id,
2498 2498 type=gist.gist_type,
2499 2499 access_id=gist.gist_access_id,
2500 2500 description=gist.gist_description,
2501 2501 url=gist.gist_url(),
2502 2502 expires=gist.gist_expires,
2503 2503 created_on=gist.created_on,
2504 2504 )
2505 2505 return data
2506 2506
2507 2507 def __json__(self):
2508 2508 data = dict(
2509 2509 )
2510 2510 data.update(self.get_api_data())
2511 2511 return data
2512 2512 ## SCM functions
2513 2513
2514 2514 @property
2515 2515 def scm_instance(self):
2516 2516 from kallithea.lib.vcs import get_repo
2517 2517 base_path = self.base_path()
2518 2518 return get_repo(os.path.join(base_path, self.gist_access_id))
2519 2519
2520 2520
2521 2521 class UserSshKeys(Base, BaseDbModel):
2522 2522 __tablename__ = 'user_ssh_keys'
2523 2523 __table_args__ = (
2524 2524 Index('usk_fingerprint_idx', 'fingerprint'),
2525 2525 UniqueConstraint('fingerprint'),
2526 2526 _table_args_default_dict
2527 2527 )
2528 2528 __mapper_args__ = {}
2529 2529
2530 2530 user_ssh_key_id = Column(Integer(), primary_key=True)
2531 2531 user_id = Column(Integer(), ForeignKey('users.user_id'), nullable=False)
2532 2532 _public_key = Column('public_key', UnicodeText(), nullable=False)
2533 2533 description = Column(UnicodeText(), nullable=False)
2534 2534 fingerprint = Column(String(255), nullable=False, unique=True)
2535 2535 created_on = Column(DateTime(timezone=False), nullable=False, default=datetime.datetime.now)
2536 2536 last_seen = Column(DateTime(timezone=False), nullable=True)
2537 2537
2538 2538 user = relationship('User')
2539 2539
2540 2540 @property
2541 2541 def public_key(self):
2542 2542 return self._public_key
2543 2543
2544 2544 @public_key.setter
2545 2545 def public_key(self, full_key):
2546 2546 # the full public key is too long to be suitable as database key - instead,
2547 2547 # use fingerprints similar to 'ssh-keygen -E sha256 -lf ~/.ssh/id_rsa.pub'
2548 2548 self._public_key = full_key
2549 2549 enc_key = safe_bytes(full_key.split(" ")[1])
2550 2550 self.fingerprint = base64.b64encode(hashlib.sha256(base64.b64decode(enc_key)).digest()).replace(b'\n', b'').rstrip(b'=').decode()
@@ -1,724 +1,724 b''
1 1 # -*- coding: utf-8 -*-
2 2 # This program is free software: you can redistribute it and/or modify
3 3 # it under the terms of the GNU General Public License as published by
4 4 # the Free Software Foundation, either version 3 of the License, or
5 5 # (at your option) any later version.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 """
15 15 kallithea.model.repo
16 16 ~~~~~~~~~~~~~~~~~~~~
17 17
18 18 Repository model for kallithea
19 19
20 20 This file was forked by the Kallithea project in July 2014.
21 21 Original author and date, and relevant copyright and licensing information is below:
22 22 :created_on: Jun 5, 2010
23 23 :author: marcink
24 24 :copyright: (c) 2013 RhodeCode GmbH, and others.
25 25 :license: GPLv3, see LICENSE.md for more details.
26 26
27 27 """
28 28
29 29 import logging
30 30 import os
31 31 import shutil
32 32 import traceback
33 33 from datetime import datetime
34 34
35 35 import kallithea.lib.utils2
36 36 from kallithea.lib import helpers as h
37 37 from kallithea.lib.auth import HasRepoPermissionLevel, HasUserGroupPermissionLevel
38 38 from kallithea.lib.caching_query import FromCache
39 39 from kallithea.lib.exceptions import AttachedForksError
40 40 from kallithea.lib.hooks import log_delete_repository
41 41 from kallithea.lib.utils import is_valid_repo_uri, make_ui
42 42 from kallithea.lib.utils2 import LazyProperty, get_current_authuser, obfuscate_url_pw, remove_prefix
43 43 from kallithea.lib.vcs.backends import get_backend
44 from kallithea.model.db import (
45 Permission, RepoGroup, Repository, RepositoryField, Session, Statistics, Ui, User, UserGroup, UserGroupRepoGroupToPerm, UserGroupRepoToPerm, UserRepoGroupToPerm, UserRepoToPerm)
44 from kallithea.model.db import (Permission, RepoGroup, Repository, RepositoryField, Session, Statistics, Ui, User, UserGroup, UserGroupRepoGroupToPerm,
45 UserGroupRepoToPerm, UserRepoGroupToPerm, UserRepoToPerm)
46 46
47 47
48 48 log = logging.getLogger(__name__)
49 49
50 50
51 51 class RepoModel(object):
52 52
53 53 URL_SEPARATOR = Repository.url_sep()
54 54
55 55 def _create_default_perms(self, repository, private):
56 56 # create default permission
57 57 default = 'repository.read'
58 58 def_user = User.get_default_user()
59 59 for p in def_user.user_perms:
60 60 if p.permission.permission_name.startswith('repository.'):
61 61 default = p.permission.permission_name
62 62 break
63 63
64 64 default_perm = 'repository.none' if private else default
65 65
66 66 repo_to_perm = UserRepoToPerm()
67 67 repo_to_perm.permission = Permission.get_by_key(default_perm)
68 68
69 69 repo_to_perm.repository = repository
70 70 repo_to_perm.user_id = def_user.user_id
71 71 Session().add(repo_to_perm)
72 72
73 73 return repo_to_perm
74 74
75 75 @LazyProperty
76 76 def repos_path(self):
77 77 """
78 78 Gets the repositories root path from database
79 79 """
80 80
81 81 q = Ui.query().filter(Ui.ui_key == '/').one()
82 82 return q.ui_value
83 83
84 84 def get(self, repo_id, cache=False):
85 85 repo = Repository.query() \
86 86 .filter(Repository.repo_id == repo_id)
87 87
88 88 if cache:
89 89 repo = repo.options(FromCache("sql_cache_short",
90 90 "get_repo_%s" % repo_id))
91 91 return repo.scalar()
92 92
93 93 def get_repo(self, repository):
94 94 return Repository.guess_instance(repository)
95 95
96 96 def get_by_repo_name(self, repo_name, cache=False):
97 97 repo = Repository.query() \
98 98 .filter(Repository.repo_name == repo_name)
99 99
100 100 if cache:
101 101 repo = repo.options(FromCache("sql_cache_short",
102 102 "get_repo_%s" % repo_name))
103 103 return repo.scalar()
104 104
105 105 def get_all_user_repos(self, user):
106 106 """
107 107 Gets all repositories that user have at least read access
108 108
109 109 :param user:
110 110 """
111 111 from kallithea.lib.auth import AuthUser
112 112 auth_user = AuthUser(dbuser=User.guess_instance(user))
113 113 repos = [repo_name
114 114 for repo_name, perm in auth_user.permissions['repositories'].items()
115 115 if perm in ['repository.read', 'repository.write', 'repository.admin']
116 116 ]
117 117 return Repository.query().filter(Repository.repo_name.in_(repos))
118 118
119 119 @classmethod
120 120 def _render_datatable(cls, tmpl, *args, **kwargs):
121 121 from tg import tmpl_context as c, request, app_globals
122 122 from tg.i18n import ugettext as _
123 123
124 124 _tmpl_lookup = app_globals.mako_lookup
125 125 template = _tmpl_lookup.get_template('data_table/_dt_elements.html')
126 126
127 127 tmpl = template.get_def(tmpl)
128 128 kwargs.update(dict(_=_, h=h, c=c, request=request))
129 129 return tmpl.render_unicode(*args, **kwargs)
130 130
131 131 def get_repos_as_dict(self, repos_list, repo_groups_list=None,
132 132 admin=False,
133 133 short_name=False):
134 134 """Return repository list for use by DataTable.
135 135 repos_list: list of repositories - but will be filtered for read permission.
136 136 repo_groups_list: added at top of list without permission check.
137 137 admin: return data for action column.
138 138 """
139 139 _render = self._render_datatable
140 140 from tg import tmpl_context as c, request
141 141 from kallithea.model.scm import ScmModel
142 142
143 143 def repo_lnk(name, rtype, rstate, private, fork_of):
144 144 return _render('repo_name', name, rtype, rstate, private, fork_of,
145 145 short_name=short_name)
146 146
147 147 def following(repo_id, is_following):
148 148 return _render('following', repo_id, is_following)
149 149
150 150 def last_change(last_change):
151 151 return _render("last_change", last_change)
152 152
153 153 def rss_lnk(repo_name):
154 154 return _render("rss", repo_name)
155 155
156 156 def atom_lnk(repo_name):
157 157 return _render("atom", repo_name)
158 158
159 159 def last_rev(repo_name, cs_cache):
160 160 return _render('revision', repo_name, cs_cache.get('revision'),
161 161 cs_cache.get('raw_id'), cs_cache.get('author'),
162 162 cs_cache.get('message'))
163 163
164 164 def desc(desc):
165 165 return h.urlify_text(desc, truncate=80, stylize=c.visual.stylify_metalabels)
166 166
167 167 def state(repo_state):
168 168 return _render("repo_state", repo_state)
169 169
170 170 def repo_actions(repo_name):
171 171 return _render('repo_actions', repo_name)
172 172
173 173 def owner_actions(owner_id, username):
174 174 return _render('user_name', owner_id, username)
175 175
176 176 repos_data = []
177 177
178 178 for gr in repo_groups_list or []:
179 179 repos_data.append(dict(
180 180 raw_name='\0' + gr.name, # sort before repositories
181 181 just_name=gr.name,
182 182 name=_render('group_name_html', group_name=gr.group_name, name=gr.name),
183 183 desc=gr.group_description))
184 184
185 185 for repo in repos_list:
186 186 if not HasRepoPermissionLevel('read')(repo.repo_name, 'get_repos_as_dict check'):
187 187 continue
188 188 cs_cache = repo.changeset_cache
189 189 row = {
190 190 "raw_name": repo.repo_name,
191 191 "just_name": repo.just_name,
192 192 "name": repo_lnk(repo.repo_name, repo.repo_type,
193 193 repo.repo_state, repo.private, repo.fork),
194 194 "following": following(
195 195 repo.repo_id,
196 196 ScmModel().is_following_repo(repo.repo_name, request.authuser.user_id),
197 197 ),
198 198 "last_change_iso": repo.last_db_change.isoformat(),
199 199 "last_change": last_change(repo.last_db_change),
200 200 "last_changeset": last_rev(repo.repo_name, cs_cache),
201 201 "last_rev_raw": cs_cache.get('revision'),
202 202 "desc": desc(repo.description),
203 203 "owner": h.person(repo.owner),
204 204 "state": state(repo.repo_state),
205 205 "rss": rss_lnk(repo.repo_name),
206 206 "atom": atom_lnk(repo.repo_name),
207 207 }
208 208 if admin:
209 209 row.update({
210 210 "action": repo_actions(repo.repo_name),
211 211 "owner": owner_actions(repo.owner_id,
212 212 h.person(repo.owner))
213 213 })
214 214 repos_data.append(row)
215 215
216 216 return {
217 217 "sort": "name",
218 218 "dir": "asc",
219 219 "records": repos_data
220 220 }
221 221
222 222 def _get_defaults(self, repo_name):
223 223 """
224 224 Gets information about repository, and returns a dict for
225 225 usage in forms
226 226
227 227 :param repo_name:
228 228 """
229 229
230 230 repo_info = Repository.get_by_repo_name(repo_name)
231 231
232 232 if repo_info is None:
233 233 return None
234 234
235 235 defaults = repo_info.get_dict()
236 236 defaults['repo_name'] = repo_info.just_name
237 237 defaults['repo_group'] = repo_info.group_id
238 238
239 239 for strip, k in [(0, 'repo_type'), (1, 'repo_enable_downloads'),
240 240 (1, 'repo_description'),
241 241 (1, 'repo_landing_rev'), (0, 'clone_uri'),
242 242 (1, 'repo_private'), (1, 'repo_enable_statistics')]:
243 243 attr = k
244 244 if strip:
245 245 attr = remove_prefix(k, 'repo_')
246 246
247 247 val = defaults[attr]
248 248 if k == 'repo_landing_rev':
249 249 val = ':'.join(defaults[attr])
250 250 defaults[k] = val
251 251 if k == 'clone_uri':
252 252 defaults['clone_uri_hidden'] = repo_info.clone_uri_hidden
253 253
254 254 # fill owner
255 255 if repo_info.owner:
256 256 defaults.update({'owner': repo_info.owner.username})
257 257 else:
258 258 replacement_user = User.query().filter(User.admin ==
259 259 True).first().username
260 260 defaults.update({'owner': replacement_user})
261 261
262 262 # fill repository users
263 263 for p in repo_info.repo_to_perm:
264 264 defaults.update({'u_perm_%s' % p.user.username:
265 265 p.permission.permission_name})
266 266
267 267 # fill repository groups
268 268 for p in repo_info.users_group_to_perm:
269 269 defaults.update({'g_perm_%s' % p.users_group.users_group_name:
270 270 p.permission.permission_name})
271 271
272 272 return defaults
273 273
274 274 def update(self, repo, **kwargs):
275 275 try:
276 276 cur_repo = Repository.guess_instance(repo)
277 277 org_repo_name = cur_repo.repo_name
278 278 if 'owner' in kwargs:
279 279 cur_repo.owner = User.get_by_username(kwargs['owner'])
280 280
281 281 if 'repo_group' in kwargs:
282 282 assert kwargs['repo_group'] != '-1', kwargs # RepoForm should have converted to None
283 283 cur_repo.group = RepoGroup.get(kwargs['repo_group'])
284 284 cur_repo.repo_name = cur_repo.get_new_name(cur_repo.just_name)
285 285 log.debug('Updating repo %s with params:%s', cur_repo, kwargs)
286 286 for k in ['repo_enable_downloads',
287 287 'repo_description',
288 288 'repo_landing_rev',
289 289 'repo_private',
290 290 'repo_enable_statistics',
291 291 ]:
292 292 if k in kwargs:
293 293 setattr(cur_repo, remove_prefix(k, 'repo_'), kwargs[k])
294 294 clone_uri = kwargs.get('clone_uri')
295 295 if clone_uri is not None and clone_uri != cur_repo.clone_uri_hidden:
296 296 # clone_uri is modified - if given a value, check it is valid
297 297 if clone_uri != '':
298 298 # will raise exception on error
299 299 is_valid_repo_uri(cur_repo.repo_type, clone_uri, make_ui())
300 300 cur_repo.clone_uri = clone_uri
301 301
302 302 if 'repo_name' in kwargs:
303 303 repo_name = kwargs['repo_name']
304 304 if kallithea.lib.utils2.repo_name_slug(repo_name) != repo_name:
305 305 raise Exception('invalid repo name %s' % repo_name)
306 306 cur_repo.repo_name = cur_repo.get_new_name(repo_name)
307 307
308 308 # if private flag is set, reset default permission to NONE
309 309 if kwargs.get('repo_private'):
310 310 EMPTY_PERM = 'repository.none'
311 311 RepoModel().grant_user_permission(
312 312 repo=cur_repo, user='default', perm=EMPTY_PERM
313 313 )
314 314 # handle extra fields
315 315 for field in [k for k in kwargs if k.startswith(RepositoryField.PREFIX)]:
316 316 k = RepositoryField.un_prefix_key(field)
317 317 ex_field = RepositoryField.get_by_key_name(key=k, repo=cur_repo)
318 318 if ex_field:
319 319 ex_field.field_value = kwargs[field]
320 320
321 321 if org_repo_name != cur_repo.repo_name:
322 322 # rename repository
323 323 self._rename_filesystem_repo(old=org_repo_name, new=cur_repo.repo_name)
324 324
325 325 return cur_repo
326 326 except Exception:
327 327 log.error(traceback.format_exc())
328 328 raise
329 329
330 330 def _create_repo(self, repo_name, repo_type, description, owner,
331 331 private=False, clone_uri=None, repo_group=None,
332 332 landing_rev='rev:tip', fork_of=None,
333 333 copy_fork_permissions=False, enable_statistics=False,
334 334 enable_downloads=False,
335 335 copy_group_permissions=False, state=Repository.STATE_PENDING):
336 336 """
337 337 Create repository inside database with PENDING state. This should only be
338 338 executed by create() repo, with exception of importing existing repos.
339 339
340 340 """
341 341 from kallithea.model.scm import ScmModel
342 342
343 343 owner = User.guess_instance(owner)
344 344 fork_of = Repository.guess_instance(fork_of)
345 345 repo_group = RepoGroup.guess_instance(repo_group)
346 346 try:
347 347 repo_name = repo_name
348 348 description = description
349 349 # repo name is just a name of repository
350 350 # while repo_name_full is a full qualified name that is combined
351 351 # with name and path of group
352 352 repo_name_full = repo_name
353 353 repo_name = repo_name.split(self.URL_SEPARATOR)[-1]
354 354 if kallithea.lib.utils2.repo_name_slug(repo_name) != repo_name:
355 355 raise Exception('invalid repo name %s' % repo_name)
356 356
357 357 new_repo = Repository()
358 358 new_repo.repo_state = state
359 359 new_repo.enable_statistics = False
360 360 new_repo.repo_name = repo_name_full
361 361 new_repo.repo_type = repo_type
362 362 new_repo.owner = owner
363 363 new_repo.group = repo_group
364 364 new_repo.description = description or repo_name
365 365 new_repo.private = private
366 366 if clone_uri:
367 367 # will raise exception on error
368 368 is_valid_repo_uri(repo_type, clone_uri, make_ui())
369 369 new_repo.clone_uri = clone_uri
370 370 new_repo.landing_rev = landing_rev
371 371
372 372 new_repo.enable_statistics = enable_statistics
373 373 new_repo.enable_downloads = enable_downloads
374 374
375 375 if fork_of:
376 376 parent_repo = fork_of
377 377 new_repo.fork = parent_repo
378 378
379 379 Session().add(new_repo)
380 380
381 381 if fork_of and copy_fork_permissions:
382 382 repo = fork_of
383 383 user_perms = UserRepoToPerm.query() \
384 384 .filter(UserRepoToPerm.repository == repo).all()
385 385 group_perms = UserGroupRepoToPerm.query() \
386 386 .filter(UserGroupRepoToPerm.repository == repo).all()
387 387
388 388 for perm in user_perms:
389 389 UserRepoToPerm.create(perm.user, new_repo, perm.permission)
390 390
391 391 for perm in group_perms:
392 392 UserGroupRepoToPerm.create(perm.users_group, new_repo,
393 393 perm.permission)
394 394
395 395 elif repo_group and copy_group_permissions:
396 396
397 397 user_perms = UserRepoGroupToPerm.query() \
398 398 .filter(UserRepoGroupToPerm.group == repo_group).all()
399 399
400 400 group_perms = UserGroupRepoGroupToPerm.query() \
401 401 .filter(UserGroupRepoGroupToPerm.group == repo_group).all()
402 402
403 403 for perm in user_perms:
404 404 perm_name = perm.permission.permission_name.replace('group.', 'repository.')
405 405 perm_obj = Permission.get_by_key(perm_name)
406 406 UserRepoToPerm.create(perm.user, new_repo, perm_obj)
407 407
408 408 for perm in group_perms:
409 409 perm_name = perm.permission.permission_name.replace('group.', 'repository.')
410 410 perm_obj = Permission.get_by_key(perm_name)
411 411 UserGroupRepoToPerm.create(perm.users_group, new_repo, perm_obj)
412 412
413 413 else:
414 414 self._create_default_perms(new_repo, private)
415 415
416 416 # now automatically start following this repository as owner
417 417 ScmModel().toggle_following_repo(new_repo.repo_id, owner.user_id)
418 418 # we need to flush here, in order to check if database won't
419 419 # throw any exceptions, create filesystem dirs at the very end
420 420 Session().flush()
421 421 return new_repo
422 422 except Exception:
423 423 log.error(traceback.format_exc())
424 424 raise
425 425
426 426 def create(self, form_data, cur_user):
427 427 """
428 428 Create repository using celery tasks
429 429
430 430 :param form_data:
431 431 :param cur_user:
432 432 """
433 433 from kallithea.lib.celerylib import tasks
434 434 return tasks.create_repo(form_data, cur_user)
435 435
436 436 def _update_permissions(self, repo, perms_new=None, perms_updates=None,
437 437 check_perms=True):
438 438 if not perms_new:
439 439 perms_new = []
440 440 if not perms_updates:
441 441 perms_updates = []
442 442
443 443 # update permissions
444 444 for member, perm, member_type in perms_updates:
445 445 if member_type == 'user':
446 446 # this updates existing one
447 447 self.grant_user_permission(
448 448 repo=repo, user=member, perm=perm
449 449 )
450 450 else:
451 451 # check if we have permissions to alter this usergroup's access
452 452 if not check_perms or HasUserGroupPermissionLevel('read')(member):
453 453 self.grant_user_group_permission(
454 454 repo=repo, group_name=member, perm=perm
455 455 )
456 456 # set new permissions
457 457 for member, perm, member_type in perms_new:
458 458 if member_type == 'user':
459 459 self.grant_user_permission(
460 460 repo=repo, user=member, perm=perm
461 461 )
462 462 else:
463 463 # check if we have permissions to alter this usergroup's access
464 464 if not check_perms or HasUserGroupPermissionLevel('read')(member):
465 465 self.grant_user_group_permission(
466 466 repo=repo, group_name=member, perm=perm
467 467 )
468 468
469 469 def create_fork(self, form_data, cur_user):
470 470 """
471 471 Simple wrapper into executing celery task for fork creation
472 472
473 473 :param form_data:
474 474 :param cur_user:
475 475 """
476 476 from kallithea.lib.celerylib import tasks
477 477 return tasks.create_repo_fork(form_data, cur_user)
478 478
479 479 def delete(self, repo, forks=None, fs_remove=True, cur_user=None):
480 480 """
481 481 Delete given repository, forks parameter defines what do do with
482 482 attached forks. Throws AttachedForksError if deleted repo has attached
483 483 forks
484 484
485 485 :param repo:
486 486 :param forks: str 'delete' or 'detach'
487 487 :param fs_remove: remove(archive) repo from filesystem
488 488 """
489 489 if not cur_user:
490 490 cur_user = getattr(get_current_authuser(), 'username', None)
491 491 repo = Repository.guess_instance(repo)
492 492 if repo is not None:
493 493 if forks == 'detach':
494 494 for r in repo.forks:
495 495 r.fork = None
496 496 elif forks == 'delete':
497 497 for r in repo.forks:
498 498 self.delete(r, forks='delete')
499 499 elif [f for f in repo.forks]:
500 500 raise AttachedForksError()
501 501
502 502 old_repo_dict = repo.get_dict()
503 503 try:
504 504 Session().delete(repo)
505 505 if fs_remove:
506 506 self._delete_filesystem_repo(repo)
507 507 else:
508 508 log.debug('skipping removal from filesystem')
509 509 log_delete_repository(old_repo_dict,
510 510 deleted_by=cur_user)
511 511 except Exception:
512 512 log.error(traceback.format_exc())
513 513 raise
514 514
515 515 def grant_user_permission(self, repo, user, perm):
516 516 """
517 517 Grant permission for user on given repository, or update existing one
518 518 if found
519 519
520 520 :param repo: Instance of Repository, repository_id, or repository name
521 521 :param user: Instance of User, user_id or username
522 522 :param perm: Instance of Permission, or permission_name
523 523 """
524 524 user = User.guess_instance(user)
525 525 repo = Repository.guess_instance(repo)
526 526 permission = Permission.guess_instance(perm)
527 527
528 528 # check if we have that permission already
529 529 obj = UserRepoToPerm.query() \
530 530 .filter(UserRepoToPerm.user == user) \
531 531 .filter(UserRepoToPerm.repository == repo) \
532 532 .scalar()
533 533 if obj is None:
534 534 # create new !
535 535 obj = UserRepoToPerm()
536 536 Session().add(obj)
537 537 obj.repository = repo
538 538 obj.user = user
539 539 obj.permission = permission
540 540 log.debug('Granted perm %s to %s on %s', perm, user, repo)
541 541 return obj
542 542
543 543 def revoke_user_permission(self, repo, user):
544 544 """
545 545 Revoke permission for user on given repository
546 546
547 547 :param repo: Instance of Repository, repository_id, or repository name
548 548 :param user: Instance of User, user_id or username
549 549 """
550 550
551 551 user = User.guess_instance(user)
552 552 repo = Repository.guess_instance(repo)
553 553
554 554 obj = UserRepoToPerm.query() \
555 555 .filter(UserRepoToPerm.repository == repo) \
556 556 .filter(UserRepoToPerm.user == user) \
557 557 .scalar()
558 558 if obj is not None:
559 559 Session().delete(obj)
560 560 log.debug('Revoked perm on %s on %s', repo, user)
561 561
562 562 def grant_user_group_permission(self, repo, group_name, perm):
563 563 """
564 564 Grant permission for user group on given repository, or update
565 565 existing one if found
566 566
567 567 :param repo: Instance of Repository, repository_id, or repository name
568 568 :param group_name: Instance of UserGroup, users_group_id,
569 569 or user group name
570 570 :param perm: Instance of Permission, or permission_name
571 571 """
572 572 repo = Repository.guess_instance(repo)
573 573 group_name = UserGroup.guess_instance(group_name)
574 574 permission = Permission.guess_instance(perm)
575 575
576 576 # check if we have that permission already
577 577 obj = UserGroupRepoToPerm.query() \
578 578 .filter(UserGroupRepoToPerm.users_group == group_name) \
579 579 .filter(UserGroupRepoToPerm.repository == repo) \
580 580 .scalar()
581 581
582 582 if obj is None:
583 583 # create new
584 584 obj = UserGroupRepoToPerm()
585 585 Session().add(obj)
586 586
587 587 obj.repository = repo
588 588 obj.users_group = group_name
589 589 obj.permission = permission
590 590 log.debug('Granted perm %s to %s on %s', perm, group_name, repo)
591 591 return obj
592 592
593 593 def revoke_user_group_permission(self, repo, group_name):
594 594 """
595 595 Revoke permission for user group on given repository
596 596
597 597 :param repo: Instance of Repository, repository_id, or repository name
598 598 :param group_name: Instance of UserGroup, users_group_id,
599 599 or user group name
600 600 """
601 601 repo = Repository.guess_instance(repo)
602 602 group_name = UserGroup.guess_instance(group_name)
603 603
604 604 obj = UserGroupRepoToPerm.query() \
605 605 .filter(UserGroupRepoToPerm.repository == repo) \
606 606 .filter(UserGroupRepoToPerm.users_group == group_name) \
607 607 .scalar()
608 608 if obj is not None:
609 609 Session().delete(obj)
610 610 log.debug('Revoked perm to %s on %s', repo, group_name)
611 611
612 612 def delete_stats(self, repo_name):
613 613 """
614 614 removes stats for given repo
615 615
616 616 :param repo_name:
617 617 """
618 618 repo = Repository.guess_instance(repo_name)
619 619 try:
620 620 obj = Statistics.query() \
621 621 .filter(Statistics.repository == repo).scalar()
622 622 if obj is not None:
623 623 Session().delete(obj)
624 624 except Exception:
625 625 log.error(traceback.format_exc())
626 626 raise
627 627
628 628 def _create_filesystem_repo(self, repo_name, repo_type, repo_group,
629 629 clone_uri=None, repo_store_location=None):
630 630 """
631 631 Makes repository on filesystem. Operation is group aware, meaning that it will create
632 632 a repository within a group, and alter the paths accordingly to the group location.
633 633
634 634 Note: clone_uri is low level and not validated - it might be a file system path used for validated cloning
635 635 """
636 636 from kallithea.lib.utils import is_valid_repo, is_valid_repo_group
637 637 from kallithea.model.scm import ScmModel
638 638
639 639 if '/' in repo_name:
640 640 raise ValueError('repo_name must not contain groups got `%s`' % repo_name)
641 641
642 642 if isinstance(repo_group, RepoGroup):
643 643 new_parent_path = os.sep.join(repo_group.full_path_splitted)
644 644 else:
645 645 new_parent_path = repo_group or ''
646 646
647 647 if repo_store_location:
648 648 _paths = [repo_store_location]
649 649 else:
650 650 _paths = [self.repos_path, new_parent_path, repo_name]
651 651 repo_path = os.path.join(*_paths)
652 652
653 653 # check if this path is not a repository
654 654 if is_valid_repo(repo_path, self.repos_path):
655 655 raise Exception('This path %s is a valid repository' % repo_path)
656 656
657 657 # check if this path is a group
658 658 if is_valid_repo_group(repo_path, self.repos_path):
659 659 raise Exception('This path %s is a valid group' % repo_path)
660 660
661 661 log.info('creating repo %s in %s from url: `%s`',
662 662 repo_name, repo_path,
663 663 obfuscate_url_pw(clone_uri))
664 664
665 665 backend = get_backend(repo_type)
666 666
667 667 if repo_type == 'hg':
668 668 baseui = make_ui()
669 669 # patch and reset hooks section of UI config to not run any
670 670 # hooks on creating remote repo
671 671 for k, v in baseui.configitems('hooks'):
672 672 baseui.setconfig('hooks', k, None)
673 673
674 674 repo = backend(repo_path, create=True, src_url=clone_uri, baseui=baseui)
675 675 elif repo_type == 'git':
676 676 repo = backend(repo_path, create=True, src_url=clone_uri, bare=True)
677 677 # add kallithea hook into this repo
678 678 ScmModel().install_git_hooks(repo=repo)
679 679 else:
680 680 raise Exception('Not supported repo_type %s expected hg/git' % repo_type)
681 681
682 682 log.debug('Created repo %s with %s backend',
683 683 repo_name, repo_type)
684 684 return repo
685 685
686 686 def _rename_filesystem_repo(self, old, new):
687 687 """
688 688 renames repository on filesystem
689 689
690 690 :param old: old name
691 691 :param new: new name
692 692 """
693 693 log.info('renaming repo from %s to %s', old, new)
694 694
695 695 old_path = os.path.join(self.repos_path, old)
696 696 new_path = os.path.join(self.repos_path, new)
697 697 if os.path.isdir(new_path):
698 698 raise Exception(
699 699 'Was trying to rename to already existing dir %s' % new_path
700 700 )
701 701 shutil.move(old_path, new_path)
702 702
703 703 def _delete_filesystem_repo(self, repo):
704 704 """
705 705 removes repo from filesystem, the removal is actually done by
706 706 renaming dir to a 'rm__*' prefix which Kallithea will skip.
707 707 It can be undeleted later by reverting the rename.
708 708
709 709 :param repo: repo object
710 710 """
711 711 rm_path = os.path.join(self.repos_path, repo.repo_name)
712 712 log.info("Removing %s", rm_path)
713 713
714 714 _now = datetime.now()
715 715 _ms = str(_now.microsecond).rjust(6, '0')
716 716 _d = 'rm__%s__%s' % (_now.strftime('%Y%m%d_%H%M%S_' + _ms),
717 717 repo.just_name)
718 718 if repo.group:
719 719 args = repo.group.full_path_splitted + [_d]
720 720 _d = os.path.join(*args)
721 721 if os.path.exists(rm_path):
722 722 shutil.move(rm_path, os.path.join(self.repos_path, _d))
723 723 else:
724 724 log.error("Can't find repo to delete in %r", rm_path)
@@ -1,382 +1,382 b''
1 1 # -*- coding: utf-8 -*-
2 2 # This program is free software: you can redistribute it and/or modify
3 3 # it under the terms of the GNU General Public License as published by
4 4 # the Free Software Foundation, either version 3 of the License, or
5 5 # (at your option) any later version.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14 """
15 15 kallithea.model.user_group
16 16 ~~~~~~~~~~~~~~~~~~~~~~~~~~
17 17
18 18 user group model for Kallithea
19 19
20 20 This file was forked by the Kallithea project in July 2014.
21 21 Original author and date, and relevant copyright and licensing information is below:
22 22 :created_on: Oct 1, 2011
23 23 :author: nvinot, marcink
24 24 """
25 25
26 26
27 27 import logging
28 28 import traceback
29 29
30 30 from kallithea.lib.exceptions import RepoGroupAssignmentError, UserGroupsAssignedException
31 from kallithea.model.db import (
32 Permission, Session, User, UserGroup, UserGroupMember, UserGroupRepoToPerm, UserGroupToPerm, UserGroupUserGroupToPerm, UserUserGroupToPerm)
31 from kallithea.model.db import (Permission, Session, User, UserGroup, UserGroupMember, UserGroupRepoToPerm, UserGroupToPerm, UserGroupUserGroupToPerm,
32 UserUserGroupToPerm)
33 33
34 34
35 35 log = logging.getLogger(__name__)
36 36
37 37
38 38 class UserGroupModel(object):
39 39
40 40 def _create_default_perms(self, user_group):
41 41 # create default permission
42 42 default_perm = 'usergroup.read'
43 43 def_user = User.get_default_user()
44 44 for p in def_user.user_perms:
45 45 if p.permission.permission_name.startswith('usergroup.'):
46 46 default_perm = p.permission.permission_name
47 47 break
48 48
49 49 user_group_to_perm = UserUserGroupToPerm()
50 50 user_group_to_perm.permission = Permission.get_by_key(default_perm)
51 51
52 52 user_group_to_perm.user_group = user_group
53 53 user_group_to_perm.user_id = def_user.user_id
54 54 Session().add(user_group_to_perm)
55 55 return user_group_to_perm
56 56
57 57 def _update_permissions(self, user_group, perms_new=None,
58 58 perms_updates=None):
59 59 from kallithea.lib.auth import HasUserGroupPermissionLevel
60 60 if not perms_new:
61 61 perms_new = []
62 62 if not perms_updates:
63 63 perms_updates = []
64 64
65 65 # update permissions
66 66 for member, perm, member_type in perms_updates:
67 67 if member_type == 'user':
68 68 # this updates existing one
69 69 self.grant_user_permission(
70 70 user_group=user_group, user=member, perm=perm
71 71 )
72 72 else:
73 73 # check if we have permissions to alter this usergroup's access
74 74 if HasUserGroupPermissionLevel('read')(member):
75 75 self.grant_user_group_permission(
76 76 target_user_group=user_group, user_group=member, perm=perm
77 77 )
78 78 # set new permissions
79 79 for member, perm, member_type in perms_new:
80 80 if member_type == 'user':
81 81 self.grant_user_permission(
82 82 user_group=user_group, user=member, perm=perm
83 83 )
84 84 else:
85 85 # check if we have permissions to alter this usergroup's access
86 86 if HasUserGroupPermissionLevel('read')(member):
87 87 self.grant_user_group_permission(
88 88 target_user_group=user_group, user_group=member, perm=perm
89 89 )
90 90
91 91 def get(self, user_group_id):
92 92 return UserGroup.get(user_group_id)
93 93
94 94 def get_group(self, user_group):
95 95 return UserGroup.guess_instance(user_group)
96 96
97 97 def get_by_name(self, name, cache=False, case_insensitive=False):
98 98 return UserGroup.get_by_group_name(name, cache=cache, case_insensitive=case_insensitive)
99 99
100 100 def create(self, name, description, owner, active=True, group_data=None):
101 101 try:
102 102 new_user_group = UserGroup()
103 103 new_user_group.owner = User.guess_instance(owner)
104 104 new_user_group.users_group_name = name
105 105 new_user_group.user_group_description = description
106 106 new_user_group.users_group_active = active
107 107 if group_data:
108 108 new_user_group.group_data = group_data
109 109 Session().add(new_user_group)
110 110 self._create_default_perms(new_user_group)
111 111
112 112 self.grant_user_permission(user_group=new_user_group,
113 113 user=owner, perm='usergroup.admin')
114 114
115 115 return new_user_group
116 116 except Exception:
117 117 log.error(traceback.format_exc())
118 118 raise
119 119
120 120 def update(self, user_group, form_data):
121 121
122 122 try:
123 123 user_group = UserGroup.guess_instance(user_group)
124 124
125 125 for k, v in form_data.items():
126 126 if k == 'users_group_members':
127 127 members_list = []
128 128 if v:
129 129 v = [v] if isinstance(v, str) else v
130 130 for u_id in set(v):
131 131 member = UserGroupMember(user_group.users_group_id, u_id)
132 132 members_list.append(member)
133 133 Session().add(member)
134 134 user_group.members = members_list
135 135 setattr(user_group, k, v)
136 136
137 137 # Flush to make db assign users_group_member_id to newly
138 138 # created UserGroupMembers.
139 139 Session().flush()
140 140 except Exception:
141 141 log.error(traceback.format_exc())
142 142 raise
143 143
144 144 def delete(self, user_group, force=False):
145 145 """
146 146 Deletes user group, unless force flag is used
147 147 raises exception if there are members in that group, else deletes
148 148 group and users
149 149
150 150 :param user_group:
151 151 :param force:
152 152 """
153 153 user_group = UserGroup.guess_instance(user_group)
154 154 try:
155 155 # check if this group is not assigned to repo
156 156 assigned_groups = UserGroupRepoToPerm.query() \
157 157 .filter(UserGroupRepoToPerm.users_group == user_group).all()
158 158 assigned_groups = [x.repository.repo_name for x in assigned_groups]
159 159
160 160 if assigned_groups and not force:
161 161 raise UserGroupsAssignedException(
162 162 'User Group assigned to %s' % ", ".join(assigned_groups))
163 163 Session().delete(user_group)
164 164 except Exception:
165 165 log.error(traceback.format_exc())
166 166 raise
167 167
168 168 def add_user_to_group(self, user_group, user):
169 169 """Return True if user already is in the group - else return the new UserGroupMember"""
170 170 user_group = UserGroup.guess_instance(user_group)
171 171 user = User.guess_instance(user)
172 172
173 173 for m in user_group.members:
174 174 u = m.user
175 175 if u.user_id == user.user_id:
176 176 # user already in the group, skip
177 177 return True
178 178
179 179 try:
180 180 user_group_member = UserGroupMember()
181 181 user_group_member.user = user
182 182 user_group_member.users_group = user_group
183 183
184 184 user_group.members.append(user_group_member)
185 185 user.group_member.append(user_group_member)
186 186
187 187 Session().add(user_group_member)
188 188 return user_group_member
189 189 except Exception:
190 190 log.error(traceback.format_exc())
191 191 raise
192 192
193 193 def remove_user_from_group(self, user_group, user):
194 194 user_group = UserGroup.guess_instance(user_group)
195 195 user = User.guess_instance(user)
196 196
197 197 user_group_member = None
198 198 for m in user_group.members:
199 199 if m.user_id == user.user_id:
200 200 # Found this user's membership row
201 201 user_group_member = m
202 202 break
203 203
204 204 if user_group_member:
205 205 try:
206 206 Session().delete(user_group_member)
207 207 return True
208 208 except Exception:
209 209 log.error(traceback.format_exc())
210 210 raise
211 211 else:
212 212 # User isn't in that group
213 213 return False
214 214
215 215 def has_perm(self, user_group, perm):
216 216 user_group = UserGroup.guess_instance(user_group)
217 217 perm = Permission.guess_instance(perm)
218 218
219 219 return UserGroupToPerm.query() \
220 220 .filter(UserGroupToPerm.users_group == user_group) \
221 221 .filter(UserGroupToPerm.permission == perm).scalar() is not None
222 222
223 223 def grant_perm(self, user_group, perm):
224 224 user_group = UserGroup.guess_instance(user_group)
225 225 perm = Permission.guess_instance(perm)
226 226
227 227 # if this permission is already granted skip it
228 228 _perm = UserGroupToPerm.query() \
229 229 .filter(UserGroupToPerm.users_group == user_group) \
230 230 .filter(UserGroupToPerm.permission == perm) \
231 231 .scalar()
232 232 if _perm:
233 233 return
234 234
235 235 new = UserGroupToPerm()
236 236 new.users_group = user_group
237 237 new.permission = perm
238 238 Session().add(new)
239 239 return new
240 240
241 241 def revoke_perm(self, user_group, perm):
242 242 user_group = UserGroup.guess_instance(user_group)
243 243 perm = Permission.guess_instance(perm)
244 244
245 245 obj = UserGroupToPerm.query() \
246 246 .filter(UserGroupToPerm.users_group == user_group) \
247 247 .filter(UserGroupToPerm.permission == perm).scalar()
248 248 if obj is not None:
249 249 Session().delete(obj)
250 250
251 251 def grant_user_permission(self, user_group, user, perm):
252 252 """
253 253 Grant permission for user on given user group, or update
254 254 existing one if found
255 255
256 256 :param user_group: Instance of UserGroup, users_group_id,
257 257 or users_group_name
258 258 :param user: Instance of User, user_id or username
259 259 :param perm: Instance of Permission, or permission_name
260 260 """
261 261
262 262 user_group = UserGroup.guess_instance(user_group)
263 263 user = User.guess_instance(user)
264 264 permission = Permission.guess_instance(perm)
265 265
266 266 # check if we have that permission already
267 267 obj = UserUserGroupToPerm.query() \
268 268 .filter(UserUserGroupToPerm.user == user) \
269 269 .filter(UserUserGroupToPerm.user_group == user_group) \
270 270 .scalar()
271 271 if obj is None:
272 272 # create new !
273 273 obj = UserUserGroupToPerm()
274 274 Session().add(obj)
275 275 obj.user_group = user_group
276 276 obj.user = user
277 277 obj.permission = permission
278 278 log.debug('Granted perm %s to %s on %s', perm, user, user_group)
279 279 return obj
280 280
281 281 def revoke_user_permission(self, user_group, user):
282 282 """
283 283 Revoke permission for user on given repository group
284 284
285 285 :param user_group: Instance of RepoGroup, repositories_group_id,
286 286 or repositories_group name
287 287 :param user: Instance of User, user_id or username
288 288 """
289 289
290 290 user_group = UserGroup.guess_instance(user_group)
291 291 user = User.guess_instance(user)
292 292
293 293 obj = UserUserGroupToPerm.query() \
294 294 .filter(UserUserGroupToPerm.user == user) \
295 295 .filter(UserUserGroupToPerm.user_group == user_group) \
296 296 .scalar()
297 297 if obj is not None:
298 298 Session().delete(obj)
299 299 log.debug('Revoked perm on %s on %s', user_group, user)
300 300
301 301 def grant_user_group_permission(self, target_user_group, user_group, perm):
302 302 """
303 303 Grant user group permission for given target_user_group
304 304
305 305 :param target_user_group:
306 306 :param user_group:
307 307 :param perm:
308 308 """
309 309 target_user_group = UserGroup.guess_instance(target_user_group)
310 310 user_group = UserGroup.guess_instance(user_group)
311 311 permission = Permission.guess_instance(perm)
312 312 # forbid assigning same user group to itself
313 313 if target_user_group == user_group:
314 314 raise RepoGroupAssignmentError('target repo:%s cannot be '
315 315 'assigned to itself' % target_user_group)
316 316
317 317 # check if we have that permission already
318 318 obj = UserGroupUserGroupToPerm.query() \
319 319 .filter(UserGroupUserGroupToPerm.target_user_group == target_user_group) \
320 320 .filter(UserGroupUserGroupToPerm.user_group == user_group) \
321 321 .scalar()
322 322 if obj is None:
323 323 # create new !
324 324 obj = UserGroupUserGroupToPerm()
325 325 Session().add(obj)
326 326 obj.user_group = user_group
327 327 obj.target_user_group = target_user_group
328 328 obj.permission = permission
329 329 log.debug('Granted perm %s to %s on %s', perm, target_user_group, user_group)
330 330 return obj
331 331
332 332 def revoke_user_group_permission(self, target_user_group, user_group):
333 333 """
334 334 Revoke user group permission for given target_user_group
335 335
336 336 :param target_user_group:
337 337 :param user_group:
338 338 """
339 339 target_user_group = UserGroup.guess_instance(target_user_group)
340 340 user_group = UserGroup.guess_instance(user_group)
341 341
342 342 obj = UserGroupUserGroupToPerm.query() \
343 343 .filter(UserGroupUserGroupToPerm.target_user_group == target_user_group) \
344 344 .filter(UserGroupUserGroupToPerm.user_group == user_group) \
345 345 .scalar()
346 346 if obj is not None:
347 347 Session().delete(obj)
348 348 log.debug('Revoked perm on %s on %s', target_user_group, user_group)
349 349
350 350 def enforce_groups(self, user, groups, extern_type=None):
351 351 user = User.guess_instance(user)
352 352 log.debug('Enforcing groups %s on user %s', user, groups)
353 353 current_groups = user.group_member
354 354 # find the external created groups
355 355 externals = [x.users_group for x in current_groups
356 356 if 'extern_type' in x.users_group.group_data]
357 357
358 358 # calculate from what groups user should be removed
359 359 # externals that are not in groups
360 360 for gr in externals:
361 361 if gr.users_group_name not in groups:
362 362 log.debug('Removing user %s from user group %s', user, gr)
363 363 self.remove_user_from_group(gr, user)
364 364
365 365 # now we calculate in which groups user should be == groups params
366 366 owner = User.get_first_admin().username
367 367 for gr in set(groups):
368 368 existing_group = UserGroup.get_by_group_name(gr)
369 369 if not existing_group:
370 370 desc = 'Automatically created from plugin:%s' % extern_type
371 371 # we use first admin account to set the owner of the group
372 372 existing_group = UserGroupModel().create(gr, desc, owner,
373 373 group_data={'extern_type': extern_type})
374 374
375 375 # we can only add users to special groups created via plugins
376 376 managed = 'extern_type' in existing_group.group_data
377 377 if managed:
378 378 log.debug('Adding user %s to user group %s', user, gr)
379 379 UserGroupModel().add_user_to_group(existing_group, user)
380 380 else:
381 381 log.debug('Skipping addition to group %s since it is '
382 382 'not managed by auth plugins' % gr)
@@ -1,436 +1,436 b''
1 1 # -*- coding: utf-8 -*-
2 2 # This program is free software: you can redistribute it and/or modify
3 3 # it under the terms of the GNU General Public License as published by
4 4 # the Free Software Foundation, either version 3 of the License, or
5 5 # (at your option) any later version.
6 6 #
7 7 # This program is distributed in the hope that it will be useful,
8 8 # but WITHOUT ANY WARRANTY; without even the implied warranty of
9 9 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
10 10 # GNU General Public License for more details.
11 11 #
12 12 # You should have received a copy of the GNU General Public License
13 13 # along with this program. If not, see <http://www.gnu.org/licenses/>.
14 14
15 15 """
16 16 Helpers for fixture generation
17 17 """
18 18
19 19 import logging
20 20 import os
21 21 import shutil
22 22 import tarfile
23 23 from os.path import dirname
24 24
25 25 import mock
26 26 from tg import request
27 27 from tg.util.webtest import test_context
28 28
29 29 from kallithea.lib import helpers
30 30 from kallithea.lib.auth import AuthUser
31 31 from kallithea.lib.db_manage import DbManage
32 32 from kallithea.lib.vcs.backends.base import EmptyChangeset
33 33 from kallithea.model.changeset_status import ChangesetStatusModel
34 34 from kallithea.model.comment import ChangesetCommentsModel
35 35 from kallithea.model.db import ChangesetStatus, Gist, RepoGroup, Repository, User, UserGroup
36 36 from kallithea.model.gist import GistModel
37 37 from kallithea.model.meta import Session
38 38 from kallithea.model.pull_request import CreatePullRequestAction # , CreatePullRequestIterationAction, PullRequestModel
39 39 from kallithea.model.repo import RepoModel
40 40 from kallithea.model.repo_group import RepoGroupModel
41 41 from kallithea.model.scm import ScmModel
42 42 from kallithea.model.user import UserModel
43 43 from kallithea.model.user_group import UserGroupModel
44 from kallithea.tests.base import (
45 GIT_REPO, HG_REPO, IP_ADDR, TEST_USER_ADMIN_EMAIL, TEST_USER_ADMIN_LOGIN, TEST_USER_REGULAR_LOGIN, TESTS_TMP_PATH, invalidate_all_caches)
44 from kallithea.tests.base import (GIT_REPO, HG_REPO, IP_ADDR, TEST_USER_ADMIN_EMAIL, TEST_USER_ADMIN_LOGIN, TEST_USER_REGULAR_LOGIN, TESTS_TMP_PATH,
45 invalidate_all_caches)
46 46
47 47
48 48 log = logging.getLogger(__name__)
49 49
50 50 FIXTURES = os.path.join(dirname(dirname(os.path.abspath(__file__))), 'tests', 'fixtures')
51 51
52 52
53 53 def error_function(*args, **kwargs):
54 54 raise Exception('Total Crash !')
55 55
56 56
57 57 class Fixture(object):
58 58
59 59 def __init__(self):
60 60 pass
61 61
62 62 def anon_access(self, status):
63 63 """
64 64 Context manager for controlling anonymous access.
65 65 Anon access will be set and committed, but restored again when exiting the block.
66 66
67 67 Usage:
68 68
69 69 fixture = Fixture()
70 70 with fixture.anon_access(False):
71 71 stuff
72 72 """
73 73
74 74 class context(object):
75 75 def __enter__(self):
76 76 anon = User.get_default_user()
77 77 self._before = anon.active
78 78 anon.active = status
79 79 Session().commit()
80 80 invalidate_all_caches()
81 81
82 82 def __exit__(self, exc_type, exc_val, exc_tb):
83 83 anon = User.get_default_user()
84 84 anon.active = self._before
85 85 Session().commit()
86 86
87 87 return context()
88 88
89 89 def _get_repo_create_params(self, **custom):
90 90 """Return form values to be validated through RepoForm"""
91 91 defs = dict(
92 92 repo_name=None,
93 93 repo_type='hg',
94 94 clone_uri='',
95 95 repo_group='-1',
96 96 repo_description='DESC',
97 97 repo_private=False,
98 98 repo_landing_rev='rev:tip',
99 99 repo_copy_permissions=False,
100 100 repo_state=Repository.STATE_CREATED,
101 101 )
102 102 defs.update(custom)
103 103 if 'repo_name_full' not in custom:
104 104 defs.update({'repo_name_full': defs['repo_name']})
105 105
106 106 # fix the repo name if passed as repo_name_full
107 107 if defs['repo_name']:
108 108 defs['repo_name'] = defs['repo_name'].split('/')[-1]
109 109
110 110 return defs
111 111
112 112 def _get_repo_group_create_params(self, **custom):
113 113 """Return form values to be validated through RepoGroupForm"""
114 114 defs = dict(
115 115 group_name=None,
116 116 group_description='DESC',
117 117 parent_group_id='-1',
118 118 perms_updates=[],
119 119 perms_new=[],
120 120 recursive=False
121 121 )
122 122 defs.update(custom)
123 123
124 124 return defs
125 125
126 126 def _get_user_create_params(self, name, **custom):
127 127 defs = dict(
128 128 username=name,
129 129 password='qweqwe',
130 130 email='%s+test@example.com' % name,
131 131 firstname='TestUser',
132 132 lastname='Test',
133 133 active=True,
134 134 admin=False,
135 135 extern_type='internal',
136 136 extern_name=None
137 137 )
138 138 defs.update(custom)
139 139
140 140 return defs
141 141
142 142 def _get_user_group_create_params(self, name, **custom):
143 143 defs = dict(
144 144 users_group_name=name,
145 145 user_group_description='DESC',
146 146 users_group_active=True,
147 147 user_group_data={},
148 148 )
149 149 defs.update(custom)
150 150
151 151 return defs
152 152
153 153 def create_repo(self, name, repo_group=None, **kwargs):
154 154 if 'skip_if_exists' in kwargs:
155 155 del kwargs['skip_if_exists']
156 156 r = Repository.get_by_repo_name(name)
157 157 if r:
158 158 return r
159 159
160 160 if isinstance(repo_group, RepoGroup):
161 161 repo_group = repo_group.group_id
162 162
163 163 form_data = self._get_repo_create_params(repo_name=name, **kwargs)
164 164 form_data['repo_group'] = repo_group # patch form dict so it can be used directly by model
165 165 cur_user = kwargs.get('cur_user', TEST_USER_ADMIN_LOGIN)
166 166 RepoModel().create(form_data, cur_user)
167 167 Session().commit()
168 168 ScmModel().mark_for_invalidation(name)
169 169 return Repository.get_by_repo_name(name)
170 170
171 171 def create_fork(self, repo_to_fork, fork_name, **kwargs):
172 172 repo_to_fork = Repository.get_by_repo_name(repo_to_fork)
173 173
174 174 form_data = self._get_repo_create_params(repo_name=fork_name,
175 175 fork_parent_id=repo_to_fork,
176 176 repo_type=repo_to_fork.repo_type,
177 177 **kwargs)
178 178 # patch form dict so it can be used directly by model
179 179 form_data['description'] = form_data['repo_description']
180 180 form_data['private'] = form_data['repo_private']
181 181 form_data['landing_rev'] = form_data['repo_landing_rev']
182 182
183 183 owner = kwargs.get('cur_user', TEST_USER_ADMIN_LOGIN)
184 184 RepoModel().create_fork(form_data, cur_user=owner)
185 185 Session().commit()
186 186 ScmModel().mark_for_invalidation(fork_name)
187 187 r = Repository.get_by_repo_name(fork_name)
188 188 assert r
189 189 return r
190 190
191 191 def destroy_repo(self, repo_name, **kwargs):
192 192 RepoModel().delete(repo_name, **kwargs)
193 193 Session().commit()
194 194
195 195 def create_repo_group(self, name, parent_group_id=None, **kwargs):
196 196 assert '/' not in name, (name, kwargs) # use group_parent_id to make nested groups
197 197 if 'skip_if_exists' in kwargs:
198 198 del kwargs['skip_if_exists']
199 199 gr = RepoGroup.get_by_group_name(group_name=name)
200 200 if gr:
201 201 return gr
202 202 form_data = self._get_repo_group_create_params(group_name=name, **kwargs)
203 203 gr = RepoGroupModel().create(
204 204 group_name=form_data['group_name'],
205 205 group_description=form_data['group_name'],
206 206 parent=parent_group_id,
207 207 owner=kwargs.get('cur_user', TEST_USER_ADMIN_LOGIN),
208 208 )
209 209 Session().commit()
210 210 gr = RepoGroup.get_by_group_name(gr.group_name)
211 211 return gr
212 212
213 213 def destroy_repo_group(self, repogroupid):
214 214 RepoGroupModel().delete(repogroupid)
215 215 Session().commit()
216 216
217 217 def create_user(self, name, **kwargs):
218 218 if 'skip_if_exists' in kwargs:
219 219 del kwargs['skip_if_exists']
220 220 user = User.get_by_username(name)
221 221 if user:
222 222 return user
223 223 form_data = self._get_user_create_params(name, **kwargs)
224 224 user = UserModel().create(form_data)
225 225 Session().commit()
226 226 user = User.get_by_username(user.username)
227 227 return user
228 228
229 229 def destroy_user(self, userid):
230 230 UserModel().delete(userid)
231 231 Session().commit()
232 232
233 233 def create_user_group(self, name, **kwargs):
234 234 if 'skip_if_exists' in kwargs:
235 235 del kwargs['skip_if_exists']
236 236 gr = UserGroup.get_by_group_name(group_name=name)
237 237 if gr:
238 238 return gr
239 239 form_data = self._get_user_group_create_params(name, **kwargs)
240 240 owner = kwargs.get('cur_user', TEST_USER_ADMIN_LOGIN)
241 241 user_group = UserGroupModel().create(
242 242 name=form_data['users_group_name'],
243 243 description=form_data['user_group_description'],
244 244 owner=owner, active=form_data['users_group_active'],
245 245 group_data=form_data['user_group_data'])
246 246 Session().commit()
247 247 user_group = UserGroup.get_by_group_name(user_group.users_group_name)
248 248 return user_group
249 249
250 250 def destroy_user_group(self, usergroupid):
251 251 UserGroupModel().delete(user_group=usergroupid, force=True)
252 252 Session().commit()
253 253
254 254 def create_gist(self, **kwargs):
255 255 form_data = {
256 256 'description': 'new-gist',
257 257 'owner': TEST_USER_ADMIN_LOGIN,
258 258 'gist_type': Gist.GIST_PUBLIC,
259 259 'lifetime': -1,
260 260 'gist_mapping': {'filename1.txt': {'content': 'hello world'}}
261 261 }
262 262 form_data.update(kwargs)
263 263 gist = GistModel().create(
264 264 description=form_data['description'], owner=form_data['owner'], ip_addr=IP_ADDR,
265 265 gist_mapping=form_data['gist_mapping'], gist_type=form_data['gist_type'],
266 266 lifetime=form_data['lifetime']
267 267 )
268 268 Session().commit()
269 269
270 270 return gist
271 271
272 272 def destroy_gists(self, gistid=None):
273 273 for g in Gist.query():
274 274 if gistid:
275 275 if gistid == g.gist_access_id:
276 276 GistModel().delete(g)
277 277 else:
278 278 GistModel().delete(g)
279 279 Session().commit()
280 280
281 281 def load_resource(self, resource_name, strip=True):
282 282 with open(os.path.join(FIXTURES, resource_name), 'rb') as f:
283 283 source = f.read()
284 284 if strip:
285 285 source = source.strip()
286 286
287 287 return source
288 288
289 289 def commit_change(self, repo, filename, content, message, vcs_type,
290 290 parent=None, newfile=False, author=None):
291 291 repo = Repository.get_by_repo_name(repo)
292 292 _cs = parent
293 293 if parent is None:
294 294 _cs = EmptyChangeset(alias=vcs_type)
295 295 if author is None:
296 296 author = '%s <%s>' % (TEST_USER_ADMIN_LOGIN, TEST_USER_ADMIN_EMAIL)
297 297
298 298 if newfile:
299 299 nodes = {
300 300 filename: {
301 301 'content': content
302 302 }
303 303 }
304 304 cs = ScmModel().create_nodes(
305 305 user=TEST_USER_ADMIN_LOGIN,
306 306 ip_addr=IP_ADDR,
307 307 repo=repo,
308 308 message=message,
309 309 nodes=nodes,
310 310 parent_cs=_cs,
311 311 author=author,
312 312 )
313 313 else:
314 314 cs = ScmModel().commit_change(
315 315 repo=repo.scm_instance, repo_name=repo.repo_name,
316 316 cs=parent,
317 317 user=TEST_USER_ADMIN_LOGIN,
318 318 ip_addr=IP_ADDR,
319 319 author=author,
320 320 message=message,
321 321 content=content,
322 322 f_path=filename
323 323 )
324 324 return cs
325 325
326 326 def review_changeset(self, repo, revision, status, author=TEST_USER_ADMIN_LOGIN):
327 327 comment = ChangesetCommentsModel().create("review comment", repo, author, revision=revision, send_email=False)
328 328 csm = ChangesetStatusModel().set_status(repo, ChangesetStatus.STATUS_APPROVED, author, comment, revision=revision)
329 329 Session().commit()
330 330 return csm
331 331
332 332 def create_pullrequest(self, testcontroller, repo_name, pr_src_rev, pr_dst_rev, title='title'):
333 333 org_ref = 'branch:stable:%s' % pr_src_rev
334 334 other_ref = 'branch:default:%s' % pr_dst_rev
335 335 with test_context(testcontroller.app): # needed to be able to mock request user
336 336 org_repo = other_repo = Repository.get_by_repo_name(repo_name)
337 337 owner_user = User.get_by_username(TEST_USER_ADMIN_LOGIN)
338 338 reviewers = [User.get_by_username(TEST_USER_REGULAR_LOGIN)]
339 339 request.authuser = AuthUser(dbuser=owner_user)
340 340 # creating a PR sends a message with an absolute URL - without routing that requires mocking
341 341 with mock.patch.object(helpers, 'url', (lambda arg, qualified=False, **kwargs: ('https://localhost' if qualified else '') + '/fake/' + arg)):
342 342 cmd = CreatePullRequestAction(org_repo, other_repo, org_ref, other_ref, title, 'No description', owner_user, reviewers)
343 343 pull_request = cmd.execute()
344 344 Session().commit()
345 345 return pull_request.pull_request_id
346 346
347 347
348 348 #==============================================================================
349 349 # Global test environment setup
350 350 #==============================================================================
351 351
352 352 def create_test_env(repos_test_path, config):
353 353 """
354 354 Makes a fresh database and
355 355 install test repository into tmp dir
356 356 """
357 357
358 358 # PART ONE create db
359 359 dbconf = config['sqlalchemy.url']
360 360 log.debug('making test db %s', dbconf)
361 361
362 362 # create test dir if it doesn't exist
363 363 if not os.path.isdir(repos_test_path):
364 364 log.debug('Creating testdir %s', repos_test_path)
365 365 os.makedirs(repos_test_path)
366 366
367 367 dbmanage = DbManage(dbconf=dbconf, root=config['here'],
368 368 tests=True)
369 369 dbmanage.create_tables(override=True)
370 370 # for tests dynamically set new root paths based on generated content
371 371 dbmanage.create_settings(dbmanage.prompt_repo_root_path(repos_test_path))
372 372 dbmanage.create_default_user()
373 373 dbmanage.admin_prompt()
374 374 dbmanage.create_permissions()
375 375 dbmanage.populate_default_permissions()
376 376 Session().commit()
377 377 # PART TWO make test repo
378 378 log.debug('making test vcs repositories')
379 379
380 380 idx_path = config['index_dir']
381 381 data_path = config['cache_dir']
382 382
383 383 # clean index and data
384 384 if idx_path and os.path.exists(idx_path):
385 385 log.debug('remove %s', idx_path)
386 386 shutil.rmtree(idx_path)
387 387
388 388 if data_path and os.path.exists(data_path):
389 389 log.debug('remove %s', data_path)
390 390 shutil.rmtree(data_path)
391 391
392 392 # CREATE DEFAULT TEST REPOS
393 393 tar = tarfile.open(os.path.join(FIXTURES, 'vcs_test_hg.tar.gz'))
394 394 tar.extractall(os.path.join(TESTS_TMP_PATH, HG_REPO))
395 395 tar.close()
396 396
397 397 tar = tarfile.open(os.path.join(FIXTURES, 'vcs_test_git.tar.gz'))
398 398 tar.extractall(os.path.join(TESTS_TMP_PATH, GIT_REPO))
399 399 tar.close()
400 400
401 401 # LOAD VCS test stuff
402 402 from kallithea.tests.vcs import setup_package
403 403 setup_package()
404 404
405 405
406 406 def create_test_index(repo_location, config, full_index):
407 407 """
408 408 Makes default test index
409 409 """
410 410
411 411 from kallithea.lib.indexers.daemon import WhooshIndexingDaemon
412 412 from kallithea.lib.pidlock import DaemonLock
413 413
414 414 index_location = os.path.join(config['index_dir'])
415 415 if not os.path.exists(index_location):
416 416 os.makedirs(index_location)
417 417
418 418 l = DaemonLock(os.path.join(index_location, 'make_index.lock'))
419 419 WhooshIndexingDaemon(index_location=index_location,
420 420 repo_location=repo_location) \
421 421 .run(full_index=full_index)
422 422 l.release()
423 423
424 424
425 425 def failing_test_hook(ui, repo, **kwargs):
426 426 ui.write(b"failing_test_hook failed\n")
427 427 return 1
428 428
429 429
430 430 def exception_test_hook(ui, repo, **kwargs):
431 431 raise Exception("exception_test_hook threw an exception")
432 432
433 433
434 434 def passing_test_hook(ui, repo, **kwargs):
435 435 ui.write(b"passing_test_hook succeeded\n")
436 436 return 0
@@ -1,54 +1,54 b''
1 1 """
2 2 Unit tests configuration module for vcs.
3 3 """
4 4 import os
5 5 import uuid
6 6
7 7 # Retrieve the necessary configuration options from the test base
8 8 # module. Some of these configuration options are subsequently
9 9 # consumed by the VCS test module.
10 from kallithea.tests.base import (
11 GIT_REMOTE_REPO, HG_REMOTE_REPO, TEST_GIT_REPO, TEST_GIT_REPO_CLONE, TEST_HG_REPO, TEST_HG_REPO_CLONE, TEST_HG_REPO_PULL, TESTS_TMP_PATH)
10 from kallithea.tests.base import (GIT_REMOTE_REPO, HG_REMOTE_REPO, TEST_GIT_REPO, TEST_GIT_REPO_CLONE, TEST_HG_REPO, TEST_HG_REPO_CLONE, TEST_HG_REPO_PULL,
11 TESTS_TMP_PATH)
12 12
13 13
14 14 __all__ = (
15 15 'TEST_HG_REPO', 'TEST_GIT_REPO', 'HG_REMOTE_REPO', 'GIT_REMOTE_REPO',
16 16 'TEST_HG_REPO_CLONE', 'TEST_GIT_REPO_CLONE', 'TEST_HG_REPO_PULL',
17 17 )
18 18
19 19
20 20 def get_new_dir(title=None):
21 21 """
22 22 Calculates a path for a new, non-existant, unique sub-directory in TESTS_TMP_PATH.
23 23
24 24 Resulting directory name will have format:
25 25
26 26 vcs-test-[title-]hexuuid
27 27
28 28 The "hexuuid" is a hexadecimal value of a randomly generated
29 29 UUID. Title will be added if specified.
30 30
31 31 Args:
32 32 title: Custom title to include as part of the resulting sub-directory
33 33 name. Can be useful for debugging to identify destination. Defaults
34 34 to None.
35 35
36 36 Returns:
37 37 Path to the new directory as a string.
38 38 """
39 39
40 40 test_repo_prefix = 'vcs-test'
41 41
42 42 if title:
43 43 name = "%s-%s" % (test_repo_prefix, title)
44 44 else:
45 45 name = test_repo_prefix
46 46
47 47 path = os.path.join(TESTS_TMP_PATH, name)
48 48
49 49 # Generate new hexes until we get a unique name (just in case).
50 50 hex_uuid = uuid.uuid4().hex
51 51 while os.path.exists("%s-%s" % (path, hex_uuid)):
52 52 hex_uuid = uuid.uuid4().hex
53 53
54 54 return "%s-%s" % (path, hex_uuid)
@@ -1,330 +1,330 b''
1 1 # encoding: utf-8
2 2 """
3 3 Tests so called "in memory changesets" commit API of vcs.
4 4 """
5 5
6 6 import datetime
7 7
8 8 import pytest
9 9
10 from kallithea.lib.vcs.exceptions import (
11 EmptyRepositoryError, NodeAlreadyAddedError, NodeAlreadyChangedError, NodeAlreadyExistsError, NodeAlreadyRemovedError, NodeDoesNotExistError, NodeNotChangedError)
10 from kallithea.lib.vcs.exceptions import (EmptyRepositoryError, NodeAlreadyAddedError, NodeAlreadyChangedError, NodeAlreadyExistsError, NodeAlreadyRemovedError,
11 NodeDoesNotExistError, NodeNotChangedError)
12 12 from kallithea.lib.vcs.nodes import DirNode, FileNode
13 13 from kallithea.tests.vcs.base import _BackendTestMixin
14 14
15 15
16 16 class InMemoryChangesetTestMixin(_BackendTestMixin):
17 17
18 18 @classmethod
19 19 def _get_commits(cls):
20 20 # Note: this is slightly different than the regular _get_commits methods
21 21 # as we don't actually return any commits. The creation of commits is
22 22 # handled in the tests themselves.
23 23 cls.nodes = [
24 24 FileNode('foobar', content='Foo & bar'),
25 25 FileNode('foobar2', content='Foo & bar, doubled!'),
26 26 FileNode('foo bar with spaces', content=''),
27 27 FileNode('foo/bar/baz', content='Inside'),
28 28 FileNode('foo/bar/file.bin', content='\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00;\x00\x03\x00\xfe\xff\t\x00\x06\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x1a\x00\x00\x00\x00\x00\x00\x00\x00\x10\x00\x00\x18\x00\x00\x00\x01\x00\x00\x00\xfe\xff\xff\xff\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff\xff'),
29 29 ]
30 30 commits = []
31 31 return commits
32 32
33 33 def test_add(self):
34 34 rev_count = len(self.repo.revisions)
35 35 to_add = [FileNode(node.path, content=node.content)
36 36 for node in self.nodes]
37 37 for node in to_add:
38 38 self.imc.add(node)
39 39 message = 'Added: %s' % ', '.join((node.path for node in self.nodes))
40 40 author = str(self.__class__)
41 41 changeset = self.imc.commit(message=message, author=author)
42 42
43 43 newtip = self.repo.get_changeset()
44 44 assert changeset == newtip
45 45 assert rev_count + 1 == len(self.repo.revisions)
46 46 assert newtip.message == message
47 47 assert newtip.author == author
48 48 assert not any((
49 49 self.imc.added,
50 50 self.imc.changed,
51 51 self.imc.removed
52 52 ))
53 53 for node in to_add:
54 54 assert newtip.get_node(node.path).content == node.content
55 55
56 56 def test_add_in_bulk(self):
57 57 rev_count = len(self.repo.revisions)
58 58 to_add = [FileNode(node.path, content=node.content)
59 59 for node in self.nodes]
60 60 self.imc.add(*to_add)
61 61 message = 'Added: %s' % ', '.join((node.path for node in self.nodes))
62 62 author = str(self.__class__)
63 63 changeset = self.imc.commit(message=message, author=author)
64 64
65 65 newtip = self.repo.get_changeset()
66 66 assert changeset == newtip
67 67 assert rev_count + 1 == len(self.repo.revisions)
68 68 assert newtip.message == message
69 69 assert newtip.author == author
70 70 assert not any((
71 71 self.imc.added,
72 72 self.imc.changed,
73 73 self.imc.removed
74 74 ))
75 75 for node in to_add:
76 76 assert newtip.get_node(node.path).content == node.content
77 77
78 78 def test_add_actually_adds_all_nodes_at_second_commit_too(self):
79 79 self.imc.add(FileNode('foo/bar/image.png', content='\0'))
80 80 self.imc.add(FileNode('foo/README.txt', content='readme!'))
81 81 changeset = self.imc.commit('Initial', 'joe.doe@example.com')
82 82 assert isinstance(changeset.get_node('foo'), DirNode)
83 83 assert isinstance(changeset.get_node('foo/bar'), DirNode)
84 84 assert changeset.get_node('foo/bar/image.png').content == b'\0'
85 85 assert changeset.get_node('foo/README.txt').content == b'readme!'
86 86
87 87 # commit some more files again
88 88 to_add = [
89 89 FileNode('foo/bar/foobaz/bar', content='foo'),
90 90 FileNode('foo/bar/another/bar', content='foo'),
91 91 FileNode('foo/baz.txt', content='foo'),
92 92 FileNode('foobar/foobaz/file', content='foo'),
93 93 FileNode('foobar/barbaz', content='foo'),
94 94 ]
95 95 self.imc.add(*to_add)
96 96 changeset = self.imc.commit('Another', 'joe.doe@example.com')
97 97 changeset.get_node('foo/bar/foobaz/bar').content == b'foo'
98 98 changeset.get_node('foo/bar/another/bar').content == b'foo'
99 99 changeset.get_node('foo/baz.txt').content == b'foo'
100 100 changeset.get_node('foobar/foobaz/file').content == b'foo'
101 101 changeset.get_node('foobar/barbaz').content == b'foo'
102 102
103 103 def test_add_non_ascii_files(self):
104 104 rev_count = len(self.repo.revisions)
105 105 to_add = [
106 106 FileNode('żółwik/zwierzątko', content='ćććć'),
107 107 FileNode('żółwik/zwierzątko_uni', content='ćććć'),
108 108 ]
109 109 for node in to_add:
110 110 self.imc.add(node)
111 111 message = 'Added: %s' % ', '.join((node.path for node in self.nodes))
112 112 author = str(self.__class__)
113 113 changeset = self.imc.commit(message=message, author=author)
114 114
115 115 newtip = self.repo.get_changeset()
116 116 assert changeset == newtip
117 117 assert rev_count + 1 == len(self.repo.revisions)
118 118 assert newtip.message == message
119 119 assert newtip.author == author
120 120 assert not any((
121 121 self.imc.added,
122 122 self.imc.changed,
123 123 self.imc.removed
124 124 ))
125 125 for node in to_add:
126 126 assert newtip.get_node(node.path).content == node.content
127 127
128 128 def test_add_raise_already_added(self):
129 129 node = FileNode('foobar', content='baz')
130 130 self.imc.add(node)
131 131 with pytest.raises(NodeAlreadyAddedError):
132 132 self.imc.add(node)
133 133
134 134 def test_check_integrity_raise_already_exist(self):
135 135 node = FileNode('foobar', content='baz')
136 136 self.imc.add(node)
137 137 self.imc.commit(message='Added foobar', author=str(self))
138 138 self.imc.add(node)
139 139 with pytest.raises(NodeAlreadyExistsError):
140 140 self.imc.commit(message='new message',
141 141 author=str(self))
142 142
143 143 def test_change(self):
144 144 self.imc.add(FileNode('foo/bar/baz', content='foo'))
145 145 self.imc.add(FileNode('foo/fbar', content='foobar'))
146 146 tip = self.imc.commit('Initial', 'joe.doe@example.com')
147 147
148 148 # Change node's content
149 149 node = FileNode('foo/bar/baz', content='My **changed** content')
150 150 self.imc.change(node)
151 151 self.imc.commit('Changed %s' % node.path, 'joe.doe@example.com')
152 152
153 153 newtip = self.repo.get_changeset()
154 154 assert tip != newtip
155 155 assert tip.raw_id != newtip.raw_id
156 156 assert newtip.get_node('foo/bar/baz').content == b'My **changed** content'
157 157
158 158 def test_change_non_ascii(self):
159 159 to_add = [
160 160 FileNode('żółwik/zwierzątko', content='ćććć'),
161 161 FileNode('żółwik/zwierzątko_uni', content='ćććć'),
162 162 ]
163 163 for node in to_add:
164 164 self.imc.add(node)
165 165
166 166 tip = self.imc.commit('Initial', 'joe.doe@example.com')
167 167
168 168 # Change node's content
169 169 node = FileNode('żółwik/zwierzątko', content='My **changed** content')
170 170 self.imc.change(node)
171 171 self.imc.commit('Changed %s' % node.path, 'joe.doe@example.com')
172 172
173 173 node = FileNode('żółwik/zwierzątko_uni', content='My **changed** content')
174 174 self.imc.change(node)
175 175 self.imc.commit('Changed %s' % node.path, 'joe.doe@example.com')
176 176
177 177 newtip = self.repo.get_changeset()
178 178 assert tip != newtip
179 179 assert tip.raw_id != newtip.raw_id
180 180
181 181 assert newtip.get_node('żółwik/zwierzątko').content == b'My **changed** content'
182 182 assert newtip.get_node('żółwik/zwierzątko_uni').content == b'My **changed** content'
183 183
184 184 def test_change_raise_empty_repository(self):
185 185 node = FileNode('foobar')
186 186 with pytest.raises(EmptyRepositoryError):
187 187 self.imc.change(node)
188 188
189 189 def test_check_integrity_change_raise_node_does_not_exist(self):
190 190 node = FileNode('foobar', content='baz')
191 191 self.imc.add(node)
192 192 self.imc.commit(message='Added foobar', author=str(self))
193 193 node = FileNode('not-foobar', content='')
194 194 self.imc.change(node)
195 195 with pytest.raises(NodeDoesNotExistError):
196 196 self.imc.commit(message='Changed not existing node', author=str(self))
197 197
198 198 def test_change_raise_node_already_changed(self):
199 199 node = FileNode('foobar', content='baz')
200 200 self.imc.add(node)
201 201 self.imc.commit(message='Added foobar', author=str(self))
202 202 node = FileNode('foobar', content='more baz')
203 203 self.imc.change(node)
204 204 with pytest.raises(NodeAlreadyChangedError):
205 205 self.imc.change(node)
206 206
207 207 def test_check_integrity_change_raise_node_not_changed(self):
208 208 self.test_add() # Performs first commit
209 209
210 210 node = FileNode(self.nodes[0].path, content=self.nodes[0].content)
211 211 self.imc.change(node)
212 212 with pytest.raises(NodeNotChangedError):
213 213 self.imc.commit(
214 214 message='Trying to mark node as changed without touching it',
215 215 author=str(self),
216 216 )
217 217
218 218 def test_change_raise_node_already_removed(self):
219 219 node = FileNode('foobar', content='baz')
220 220 self.imc.add(node)
221 221 self.imc.commit(message='Added foobar', author=str(self))
222 222 self.imc.remove(FileNode('foobar'))
223 223 with pytest.raises(NodeAlreadyRemovedError):
224 224 self.imc.change(node)
225 225
226 226 def test_remove(self):
227 227 self.test_add() # Performs first commit
228 228
229 229 tip = self.repo.get_changeset()
230 230 node = self.nodes[0]
231 231 assert node.content == tip.get_node(node.path).content
232 232 self.imc.remove(node)
233 233 self.imc.commit(message='Removed %s' % node.path, author=str(self))
234 234
235 235 newtip = self.repo.get_changeset()
236 236 assert tip != newtip
237 237 assert tip.raw_id != newtip.raw_id
238 238 with pytest.raises(NodeDoesNotExistError):
239 239 newtip.get_node(node.path)
240 240
241 241 def test_remove_last_file_from_directory(self):
242 242 node = FileNode('omg/qwe/foo/bar', content='foobar')
243 243 self.imc.add(node)
244 244 self.imc.commit('added', 'joe doe')
245 245
246 246 self.imc.remove(node)
247 247 tip = self.imc.commit('removed', 'joe doe')
248 248 with pytest.raises(NodeDoesNotExistError):
249 249 tip.get_node('omg/qwe/foo/bar')
250 250
251 251 def test_remove_raise_node_does_not_exist(self):
252 252 self.imc.remove(self.nodes[0])
253 253 with pytest.raises(NodeDoesNotExistError):
254 254 self.imc.commit(
255 255 message='Trying to remove node at empty repository',
256 256 author=str(self),
257 257 )
258 258
259 259 def test_check_integrity_remove_raise_node_does_not_exist(self):
260 260 self.test_add() # Performs first commit
261 261
262 262 node = FileNode('no-such-file')
263 263 self.imc.remove(node)
264 264 with pytest.raises(NodeDoesNotExistError):
265 265 self.imc.commit(
266 266 message='Trying to remove not existing node',
267 267 author=str(self),
268 268 )
269 269
270 270 def test_remove_raise_node_already_removed(self):
271 271 self.test_add() # Performs first commit
272 272
273 273 node = FileNode(self.nodes[0].path)
274 274 self.imc.remove(node)
275 275 with pytest.raises(NodeAlreadyRemovedError):
276 276 self.imc.remove(node)
277 277
278 278 def test_remove_raise_node_already_changed(self):
279 279 self.test_add() # Performs first commit
280 280
281 281 node = FileNode(self.nodes[0].path, content='Bending time')
282 282 self.imc.change(node)
283 283 with pytest.raises(NodeAlreadyChangedError):
284 284 self.imc.remove(node)
285 285
286 286 def test_reset(self):
287 287 self.imc.add(FileNode('foo', content='bar'))
288 288 #self.imc.change(FileNode('baz', content='new'))
289 289 #self.imc.remove(FileNode('qwe'))
290 290 self.imc.reset()
291 291 assert not any((
292 292 self.imc.added,
293 293 self.imc.changed,
294 294 self.imc.removed
295 295 ))
296 296
297 297 def test_multiple_commits(self):
298 298 N = 3 # number of commits to perform
299 299 last = None
300 300 for x in range(N):
301 301 fname = 'file%s' % str(x).rjust(5, '0')
302 302 content = 'foobar\n' * x
303 303 node = FileNode(fname, content=content)
304 304 self.imc.add(node)
305 305 commit = self.imc.commit("Commit no. %s" % (x + 1), author='vcs')
306 306 assert last != commit
307 307 last = commit
308 308
309 309 # Check commit number for same repo
310 310 assert len(self.repo.revisions) == N
311 311
312 312 # Check commit number for recreated repo
313 313 assert len(self.repo.revisions) == N
314 314
315 315 def test_date_attr(self):
316 316 node = FileNode('foobar.txt', content='Foobared!')
317 317 self.imc.add(node)
318 318 date = datetime.datetime(1985, 1, 30, 1, 45)
319 319 commit = self.imc.commit("Committed at time when I was born ;-)",
320 320 author='lb <lb@example.com>', date=date)
321 321
322 322 assert commit.date == date
323 323
324 324
325 325 class TestGitInMemoryChangeset(InMemoryChangesetTestMixin):
326 326 backend_alias = 'git'
327 327
328 328
329 329 class TestHgInMemoryChangeset(InMemoryChangesetTestMixin):
330 330 backend_alias = 'hg'
@@ -1,23 +1,23 b''
1 1 #!/bin/bash -x
2 2
3 3 # Enforce some consistency in whitespace - just to avoid spurious whitespaces changes
4 4
5 5 files=`hg mani | egrep -v '/fontello/|/email_templates/|(^LICENSE-MERGELY.html|^docs/Makefile|^scripts/whitespacecleanup.sh|/(graph|mergely|native.history)\.js|/test_dump_html_mails.ref.html|\.png|\.gif|\.ico|\.pot|\.po|\.mo|\.tar\.gz|\.diff)$'`
6 6
7 7 sed -i "s/`printf '\r'`//g" $files
8 8 sed -i -e "s,`printf '\t'`, ,g" $files
9 9 sed -i -e "s, *$,,g" $files
10 10 sed -i -e 's,\([^ ]\)\\$,\1 \\,g' -e 's,\(["'"'"']["'"'"']["'"'"']\) \\$,\1\\,g' $files
11 11 # ensure one trailing newline - remove empty last line and make last line include trailing newline:
12 12 sed -i -e '$,${/^$/d}' -e '$a\' $files
13 13
14 14 sed -i -e 's,\([^ /]\){,\1 {,g' `hg loc '*.css'`
15 15 sed -i -e 's|^\([^ /].*,\)\([^ ]\)|\1 \2|g' `hg loc '*.css'`
16 16
17 17 hg mani | xargs chmod -x
18 18 hg loc 'set:!binary()&grep("^#!")&!(**_tmpl.py)&!(**/template**)' | xargs chmod +x
19 19
20 20 # isort is installed from dev_requirements.txt
21 hg loc 'set:!binary()&grep("^#!.*python")' '*.py' | xargs isort --line-width 160 --wrap-length 160 --lines-after-imports 2
21 hg loc 'set:!binary()&grep("^#!.*python")' '*.py' | xargs isort --line-width 160 --lines-after-imports 2
22 22
23 23 hg diff
General Comments 0
You need to be logged in to leave comments. Login now