source: repositorymanagerplugin/repo_mgr/web_ui.py @ 73:06bfa9e5819c

tip
Last change on this file since 73:06bfa9e5819c was 73:06bfa9e5819c, checked in by Tobias Föhst <foehst@…>, 7 years ago

Adds support for deleting and banning changesets from managed repositories

File size: 23.8 KB
Line 
1from api import *
2
3from trac.core import *
4from trac.perm import IPermissionRequestor, PermissionError, PermissionSystem
5from trac.web import IRequestHandler, IRequestFilter
6from trac.web.auth import LoginModule
7from trac.web.chrome import INavigationContributor, ITemplateProvider, \
8                            add_ctxtnav, add_stylesheet, \
9                            add_notice, add_warning
10from trac.versioncontrol.admin import RepositoryAdminPanel
11from trac.versioncontrol.svn_authz import AuthzSourcePolicy
12from trac.ticket.model import Ticket
13from trac.util import is_path_below, as_bool
14from trac.util.translation import _, tag_
15from trac.util.text import normalize_whitespace, \
16                           unicode_to_base64, unicode_from_base64
17from trac.config import Option, BoolOption
18
19from genshi.builder import tag
20
21import os
22import re
23
24class RepositoryManagerModule(Component):
25    """The `RepositoryManager`'s user interface."""
26
27    implements(IPermissionRequestor, IRequestFilter, IRequestHandler,
28               ITemplateProvider)
29
30    restrict_dir = BoolOption('repository-manager', 'restrict_dir', True,
31                              doc="""Always use the repository name as
32                                     directory name. Disables some form
33                                     elements.
34                                     """)
35    restrict_forks = BoolOption('repository-manager', 'restrict_forks', False,
36                                doc="""Restrict users to one fork per
37                                       repository with fixed name
38                                       `username/reponame`. `REPOSITORY_ADMIN`
39                                       can still fork without restrictions.
40                                       """)
41
42    ### IPermissionRequestor methods
43    def get_permission_actions(self):
44        return ['REPOSITORY_FORK',
45                ('REPOSITORY_CREATE', ['REPOSITORY_FORK']),
46                ('REPOSITORY_ADMIN', ['REPOSITORY_CREATE', 'BROWSER_VIEW',
47                                      'FILE_VIEW', 'LOG_VIEW',
48                                      'CHANGESET_VIEW'])]
49
50    ### IRequestFilter methods
51    def pre_process_request(self, req, handler):
52        return handler
53
54    def post_process_request(self, req, template, data, content_type):
55        """Hook into requests that change the user database.
56
57        When the user database changes, we must update our auth files.
58        """
59        if req.path_info == '/admin/general/perm':
60            RepositoryManager(self.env).update_auth_files()
61
62        return template, data, content_type
63
64    ### IRequestHandler methods
65    def match_request(self, req):
66        match = re.match(r'^/repository(/(\w+))?(/(.+))?', req.path_info)
67        if match:
68            _, action, _, reponame = match.groups()
69            req.args['action'] = action or 'list'
70            req.args['reponame'] = reponame or None
71            return True
72
73    def process_request(self, req):
74        action = req.args.get('action', 'list')
75        if action == 'list':
76            req.redirect(req.href.browser())
77
78        restrict = self.restrict_forks and not 'REPOSITORY_ADMIN' in req.perm
79        data = {'action': action,
80                'restrict_dir': self.restrict_dir,
81                'restrict_forks': restrict,
82                'possible_owners': self._get_possible_owners(req),
83                'unicode_to_base64': unicode_to_base64}
84
85        if action == 'create':
86            self._process_create_request(req, data)
87        elif action == 'fork':
88            repo = self._get_checked_repository(req, req.args.get('reponame'),
89                                                False, 'REPOSITORY_FORK')
90            if not repo.is_forkable:
91                raise TracError(_("Repository is not forkable"))
92            self._process_fork_request(req, data)
93        elif action == 'modify':
94            self._process_modify_request(req, data)
95        elif action == 'remove':
96            self._process_remove_request(req, data)
97
98#        add_stylesheet(req, 'common/css/browser.css')
99        add_stylesheet(req, 'common/css/admin.css')
100        return 'repository.html', data, None
101
102    ### ITemplateProvider methods
103    def get_templates_dirs(self):
104        from pkg_resources import resource_filename
105        return [resource_filename(__name__, 'templates')]
106
107    def get_htdocs_dirs(self):
108        return []
109
110    ### Private methods
111    def _process_create_request(self, req, data):
112        """Create a new repository.
113
114        Depending on the content of `req.args` either create a new empty
115        repository, fork a locally existing one or fork a remote
116        repository.
117        """
118        req.perm.require('REPOSITORY_CREATE')
119
120        rm = RepositoryManager(self.env)
121
122        repository = self._get_repository_data_from_request(req, 'create_')
123        remote_fork = self._get_repository_data_from_request(req, 'remote_')
124
125        if req.args.get('create'):
126            self._create(req, repository, rm.create)
127
128        elif req.args.get('fork_remote'):
129            self._create(req, remote_fork, rm.fork_remote)
130
131        self._process_fork_request(req, data)
132
133        data.update({'title': _("Create Repository"),
134                     'supported_repository_types': rm.get_supported_types(),
135                     'forkable_repository_types': rm.get_forkable_types(),
136                     'forkable_repositories': rm.get_forkable_repositories(),
137                     'repository': repository,
138                     'local_fork': data.get('local_fork', {}),
139                     'remote_fork': remote_fork})
140
141    def _process_fork_request(self, req, data):
142        """Fork an existing repository."""
143        rm = RepositoryManager(self.env)
144        origin_name = req.args.get('local_origin', req.args.get('reponame'))
145
146        if self.restrict_forks and origin_name:
147            name = req.authname + '/' + origin_name
148            if not 'REPOSITORY_ADMIN' in req.perm:
149                if rm.get_repository(name):
150                    req.redirect(req.href.browser(name))
151                req.args['local_name'] = name
152
153        local_fork = self._get_repository_data_from_request(req, 'local_')
154        local_fork['origin'] = origin_name
155
156        if req.args.get('fork_local'):
157            origin = self._get_checked_repository(req, local_fork['origin'],
158                                                  False, 'REPOSITORY_FORK')
159            local_fork.update({'type': origin.type})
160            self._create(req, local_fork, rm.fork_local)
161
162        repo_link = tag.a(origin_name, href=req.href.browser(origin_name))
163        data.update({'title': tag_("Fork Repository %(link)s", link=repo_link),
164                     'local_fork': local_fork})
165
166    def _process_modify_request(self, req, data):
167        """Modify an existing repository."""
168        repo = self._get_checked_repository(req, req.args.get('reponame'))
169
170        restrict_modifications = False
171        if self.restrict_forks and not 'REPOSITORY_ADMIN' in req.perm:
172            restrict_modifications = repo.is_fork
173
174        rm = RepositoryManager(self.env)
175        base_directory = rm.get_base_directory(repo.type)
176        prefix_length = len(base_directory)
177        if prefix_length > 0:
178            prefix_length += 1
179
180        req.args['name'] = req.args.get('name', repo.reponame)
181        req.args['type'] = repo.type
182        req.args['dir'] = req.args.get('dir', repo.directory[prefix_length:])
183        req.args['owner'] = req.args.get('owner', repo.owner)
184        if repo.is_fork:
185            req.args['inherit_readers'] = req.args.get('inherit_readers',
186                                                       repo.inherit_readers)
187        new = self._get_repository_data_from_request(req)
188
189        if req.args.get('modify'):
190            if self._check_and_update_repository(req, new, repo):
191                rm.modify(repo, new)
192                link = tag.a(repo.reponame, href=req.href.browser(new['name']))
193                add_notice(req, tag_('The repository "%(link)s" has been '
194                                     'modified.', link=link))
195                req.redirect(req.href.repository('modify', new['name']))
196        elif self._process_role_adding(req, repo):
197            req.redirect(req.href(req.path_info))
198        elif req.args.get('revoke'):
199            selection = req.args.get('selection')
200            if selection:
201                if not isinstance(selection, list):
202                    selection = [selection]
203                roles = [role.split(':') for role in selection]
204                decode = unicode_from_base64
205                roles = [(decode(role[0]), decode(role[1])) for role in roles]
206                rm.revoke_roles(repo, roles)
207                rm.update_auth_files()
208                req.redirect(req.href(req.path_info))
209        elif req.args.get('cancel'):
210            LoginModule(self.env)._redirect_back(req)
211
212        if repo.is_fork:
213            if new['inherit_readers'] != repo.inherit_readers:
214                new['dir'] = repo.directory
215                rm.modify(repo, new)
216                req.redirect(req.href(req.path_info))
217
218        repo_link = tag.a(repo.reponame, href=req.href.browser(repo.reponame))
219        possible_maintainers = self._get_possible_maintainers(req)
220        data.update({'title': tag_("Modify Repository %(link)s",
221                                   link=repo_link),
222                     'repository': repo,
223                     'new': new,
224                     'users': self._get_users(),
225                     'groups': self._get_groups(),
226                     'possible_maintainers': possible_maintainers,
227                     'restrict_modifications': restrict_modifications})
228
229    def _process_remove_request(self, req, data):
230        """Remove an existing repository."""
231        repo = self._get_checked_repository(req, req.args.get('reponame'))
232
233        open_ticket = None
234        with self.env.db_transaction as db:
235            tickets = db("""SELECT ticket FROM (
236                                SELECT src.ticket,
237                                       src.value as srcrepo,
238                                      dst.value as dstrepo
239                                FROM ticket_custom AS src JOIN
240                                     ticket_custom AS dst ON
241                                     (src.ticket = dst.ticket)
242                                WHERE src.name = 'pr_srcrepo' AND
243                                      dst.name = 'pr_dstrepo')
244                            WHERE srcrepo = %d OR dstrepo = %d
245                            """ % (repo.id, repo.id))
246            for values in tickets:
247                (id,) = values
248                ticket = Ticket(self.env, id)
249                if ticket['status'] != 'closed':
250                    open_ticket = id
251                    break
252
253        if open_ticket:
254            link = tag.a(_("pull request"), href=req.href.ticket(open_ticket))
255            add_warning(req, tag_('The repository "%(name)s can not be '
256                                  'removed as it is referenced by an open '
257                                  '%(link)s.', name=repo.reponame, link=link))
258            LoginModule(self.env)._redirect_back(req)
259
260        if req.args.get('confirm'):
261            RepositoryManager(self.env).remove(repo, req.args.get('delete'))
262            add_notice(req, _('The repository "%(name)s" has been removed.',
263                              name=repo.reponame))
264            req.redirect(req.href.repository())
265        elif req.args.get('cancel'):
266            LoginModule(self.env)._redirect_back(req)
267
268        data.update({'title': _("Remove Repository"),
269                     'repository': repo})
270
271    def _get_checked_repository(self, req, name, owner=True, permission=None):
272        """Check if a repository exists and the user is the owner and
273        has the given permission. Finally return the repository.
274        """
275        if not name:
276            raise TracError(_("Repository not specified"))
277
278        rm = RepositoryManager(self.env)
279        repository = rm.get_repository(name, True)
280        if not repository:
281            raise TracError(_('Repository "%(name)s" does not exist.',
282                              name=name))
283
284        if owner and not (repository.owner == req.authname or
285                          'REPOSITORY_ADMIN' in req.perm):
286            message = _('You (%(user)s) are not the owner of "%(name)s"',
287                        user=req.authname, name=name)
288            raise PermissionError(message)
289
290        if permission and not permission in req.perm:
291            raise PermissionError(permission, None, self.env)
292
293        return repository
294
295    def _create(self, req, repo, creator):
296        """Check if a repository can be created and create it using the
297        given creator function.
298        """
299        if not repo['name']:
300            add_warning(req, _("Missing arguments to create a repository."))
301        elif self._check_and_update_repository(req, repo):
302            creator(repo)
303            link = tag.a(repo['name'], href=req.href.browser(repo['name']))
304            add_notice(req, tag_('The repository "%(link)s" has been created.',
305                                 link=link))
306            req.redirect(req.href.repository('modify', repo['name']))
307
308    def _check_and_update_repository(self, req, repo, old_repo=None):
309        """Check if a repository is valid, does not already exist,
310        update the dict and add a warning message otherwise.
311        """
312        if not repo['dir']:
313            add_warning(req, _("The directory is missing."))
314            return False
315
316        rm = RepositoryManager(self.env)
317        base_directory = rm.get_base_directory(repo['type'])
318        directory = os.path.join(base_directory, repo['dir'])
319
320        if not old_repo or old_repo.directory != directory:
321            if os.path.lexists(directory):
322                add_warning(req, _('Directory "%(name)s" already exists',
323                                   name=directory))
324                return False
325
326        rap = RepositoryAdminPanel(self.env)
327        prefixes = [os.path.join(self.env.path, prefix)
328                    for prefix in rap.allowed_repository_dir_prefixes]
329        if prefixes and not any(is_path_below(directory, prefix)
330                                for prefix in prefixes):
331            add_warning(req, _("The repository directory must be located "
332                               "below one of the following directories: "
333                               "%(dirs)s", dirs=', '.join(prefixes)))
334            return False
335
336        if not old_repo or old_repo.reponame != repo['name']:
337            if rm.get_repository(repo['name']):
338                add_warning(req, _('Repository "%(name)s" already exists',
339                                   name=repo['name']))
340                return False
341
342        repo.update({'dir': directory})
343        return True
344
345    def _get_possible_owners(self, req):
346        """Get the list of known users if `REPOSITORY_ADMIN` permission is
347        available. None otherwise.
348        """
349        if 'REPOSITORY_ADMIN' in req.perm:
350            return {u[0] for u in self.env.get_known_users()}
351        return None
352
353    def _get_possible_maintainers(self, req):
354        """Get the list of valid maintainers."""
355        return {u[0] for u in self.env.get_known_users()}
356
357    def _get_users(self):
358        """Get the list of known users."""
359        return {u[0] for u in self.env.get_known_users()}
360
361    def _get_groups(self):
362        """Get the list of known groups."""
363        ps = PermissionSystem(self.env)
364        result = list(set(perm[1] for perm in ps.get_all_permissions()
365                      if not perm[1].isupper()))
366        return result
367
368    def _process_role_adding(self, req, repo):
369        """Does all needed calls to `add_role` in `RepositoryManager`."""
370        rm = RepositoryManager(self.env)
371        for role in rm.roles:
372            if req.args.get('add_role_' + role):
373                subject = req.args.get(role)
374                if subject:
375                    rm.add_role(repo, role, subject)
376                    rm.update_auth_files()
377                    return True
378                add_warning(req, _("Please choose an option from the list."))
379        return False
380
381    def _get_repository_data_from_request(self, req, prefix=''):
382        """Fill a dict with common repository data for create/fork/modify
383        actions.
384        """
385        directory = req.args.get(prefix + 'dir')
386        if self.restrict_dir or not directory:
387            directory = req.args.get(prefix + 'name')
388        return {'name': req.args.get(prefix + 'name'),
389                'type': req.args.get(prefix + 'type'),
390                'dir': normalize_whitespace(directory),
391                'owner': req.args.get(prefix + 'owner', req.authname),
392                'inherit_readers': as_bool(req.args.get('inherit_readers'))}
393
394class BrowserModule(Component):
395    """Add navigation items to the browser."""
396
397    implements(INavigationContributor, IRequestFilter)
398
399    def __init__(self):
400        """Make sure that the configured authz file is available.
401
402        AuthzSourcePolicy requires its authz file to exist. Otherwise,
403        it would not allow to see the browser until the first occurence
404        of access configuration, which is done via the browser.
405        """
406        authz_source_file = AuthzSourcePolicy(self.env).authz_file
407        if authz_source_file:
408            authz_source_path = os.path.join(self.env.path, authz_source_file)
409            if not os.path.exists(authz_source_path):
410                RepositoryManager(self.env).update_auth_files()
411
412    ### INavigationContributor methods
413    def get_active_navigation_item(self, req):
414        return 'browser'
415
416    def get_navigation_items(self, req):
417        if 'BROWSER_VIEW' in req.perm and 'REPOSITORY_CREATE' in req.perm:
418            yield ('mainnav', 'browser',
419                   tag.a(_("Browse Source"), href=req.href.browser()))
420
421    ### IRequestFilter methods
422    def pre_process_request(self, req, handler):
423        return handler
424
425    def post_process_request(self, req, template, data, content_type):
426        if 'BROWSER_VIEW' in req.perm and re.match(r'^/browser', req.path_info):
427            rm = RepositoryManager(self.env)
428            path = req.args.get('path', '/')
429            reponame, repo, path = rm.get_repository_by_path(path)
430            if repo:
431                if path == '/':
432                    try:
433                        convert_managed_repository(self.env, repo)
434                        if 'REPOSITORY_FORK' in req.perm and repo.is_forkable:
435                            href = req.href.repository('fork', repo.reponame)
436                            add_ctxtnav(req, _("Fork"), href)
437                        if (repo.owner == req.authname or
438                            'REPOSITORY_ADMIN' in req.perm):
439                            href = req.href.repository('modify', repo.reponame)
440                            add_ctxtnav(req, _("Modify"), href)
441                            href = req.href.repository('remove', repo.reponame)
442                            add_ctxtnav(req, _("Remove"), href)
443                        if repo.is_fork:
444                            origin = repo.origin.reponame
445                            add_ctxtnav(req, _("Forked from %(origin)s",
446                                               origin=origin),
447                                        req.href.browser(origin))
448                    except:
449                        pass
450            else:
451                if 'REPOSITORY_CREATE' in req.perm:
452                    add_ctxtnav(req, _("Create Repository"),
453                                req.href.repository('create'))
454
455        return template, data, content_type
456
457class RepositoryIndex(Component):
458    """Enhanced repository index with e.g. maintainer information."""
459
460    implements(IRequestFilter, ITemplateProvider)
461
462    ### IRequestFilter methods
463    def pre_process_request(self, req, handler):
464        return handler
465
466    def post_process_request(self, req, template, data, content_type):
467        if template == 'browser.html':
468            if data['repo'] and data['repo']['repositories']:
469                for i, values in enumerate(data['repo']['repositories']):
470                    try:
471                        convert_managed_repository(self.env, values[2])
472                    except:
473                        pass
474                data['list_maintainers'] = list_maintainers
475            template = 'repo_mgr_browser.html'
476
477        return template, data, content_type
478
479    ### ITemplateProvider methods
480    def get_templates_dirs(self):
481        from pkg_resources import resource_filename
482        return [resource_filename(__name__, 'templates')]
483
484    def get_htdocs_dirs(self):
485        from pkg_resources import resource_filename
486        return [('hw', resource_filename(__name__, 'htdocs'))]
487
488    ### Private methods
489
490
491def list_maintainers(repository):
492    """Formats the list repository's maintainers for web display"""
493    try:
494        maintainers = repository.maintainers()
495        if maintainers:
496            return _("Maintainers: %(joined)s", joined=", ".join(maintainers))
497    except:
498        pass
499
500class ChangesetModule(Component):
501    """Supports deleting and banning of changesets from managed repositories"""
502
503    implements(IPermissionRequestor, IRequestFilter, IRequestHandler, ITemplateProvider)
504
505    ### IPermissionRequestor methods
506    def get_permission_actions(self):
507        return ['CHANGESET_DELETE']
508
509    ### IRequestFilter methods
510    def pre_process_request(self, req, handler):
511        return handler
512
513    def post_process_request(self, req, template, data, content_type):
514        match = re.match(r'^/changeset', req.path_info)
515        if 'CHANGESET_DELETE' in req.perm and match:
516            rev = req.args.get('new')
517            path = req.args.get('new_path')
518
519            rm = RepositoryManager(self.env)
520            if rev and path:
521                reponame, repos, path = rm.get_repository_by_path(path)
522                convert_managed_repository(self.env, repos)
523                if (path == '/' and (repos.owner == req.authname or
524                                     'REPOSITORY_ADMIN' in req.perm)
525                    and rm.can_delete_changesets(repos.type)):
526                    add_ctxtnav(req, _("Delete Changeset"),
527                                req.href.deletechangeset(rev, reponame))
528
529        return template, data, content_type
530
531    ### IRequestHandler methods
532    def match_request(self, req):
533        match = re.match(r'^/deletechangeset/([^/]+)/(.+)$', req.path_info)
534        if match:
535            rev, reponame = match.groups()
536            req.args['rev'] = rev
537            req.args['reponame'] = reponame
538            return True
539
540    def process_request(self, req):
541        req.perm.require('CHANGESET_DELETE')
542
543        rm = RepositoryManager(self.env)
544        repos = rm.get_repository(req.args['reponame'], True)
545        if not repos:
546            raise TracError(_('Repository "%(name)s" does not exist.',
547                              name=req.args['reponame']))
548
549        if not (repos.owner == req.authname or
550                'REPOSITORY_ADMIN' in req.perm):
551            message = _('You (%(user)s) are not the owner of "%(name)s"',
552                        user=req.authname, name=repos.reponame)
553            raise PermissionError(message)
554
555        if req.args.get('confirm'):
556            display_rev = repos.display_rev(req.args['rev'])
557            rm.delete_changeset(repos, req.args['rev'], req.args.get('ban'))
558            add_notice(req, _('The changeset "%(rev)s" has been removed.',
559                              rev=display_rev))
560            req.redirect(req.href.log(repos.reponame))
561        elif req.args.get('cancel'):
562            LoginModule(self.env)._redirect_back(req)
563
564        data = {'repository': repos,
565                'rev': req.args['rev'],
566                'cannot_ban': not rm.can_ban_changesets(repos.type)}
567
568        add_stylesheet(req, 'common/css/admin.css')
569        return 'changeset_delete.html', data, None
570
571    ### ITemplateProvider methods
572    def get_templates_dirs(self):
573        from pkg_resources import resource_filename
574        return [resource_filename(__name__, 'templates')]
575
576    def get_htdocs_dirs(self):
577        return []
Note: See TracBrowser for help on using the repository browser.