Removed svn-specifics cap from admin role
[pillar-svnman.git] / svnman / __init__.py
1 import logging
2 import os.path
3 import string
4 import typing
5 from urllib.parse import urljoin
6
7 import flask
8 from werkzeug.local import LocalProxy
9
10 import pillarsdk
11 from pillar.extension import PillarExtension
12 from pillar.auth import current_user
13 from pillar.api.projects import utils as proj_utils
14 from pillar import current_app
15
16 EXTENSION_NAME = 'svnman'
17
18
19 # SVNman stores the following keys in the project extension properties:
20 # repo_id: the Subversion repository ID
21 # users: list of ObjectIDs of users having access to the project
22
23 class SVNManExtension(PillarExtension):
24     user_caps = {
25         'subscriber-pro': frozenset({'svn-use'}),
26         'demo': frozenset({'svn-use'}),
27     }
28
29     def __init__(self):
30         from . import remote
31
32         self._log = logging.getLogger('%s.SVNManExtension' % __name__)
33         self.remote: remote.API = None
34
35     @property
36     def name(self):
37         return EXTENSION_NAME
38
39     def flask_config(self):
40         """Returns extension-specific defaults for the Flask configuration.
41
42         Use this to set sensible default values for configuration settings
43         introduced by the extension.
44
45         :rtype: dict
46         """
47
48         # Just so that it registers the management commands.
49         from . import cli
50
51         return {
52             'SVNMAN_REPO_URL': 'http://SVNMAN_REPO_URL/repo/',
53             'SVNMAN_API_URL': 'http://SVNMAN_API_URL/api/',
54             'SVNMAN_API_USERNAME': 'SVNMAN_API_USERNAME',
55             'SVNMAN_API_PASSWORD': 'SVNMAN_API_PASSWORD',
56         }
57
58     def eve_settings(self):
59         """Returns extensions to the Eve settings.
60
61         Currently only the DOMAIN key is used to insert new resources into
62         Eve's configuration.
63
64         :rtype: dict
65         """
66         return {'DOMAIN': {}}
67
68     def blueprints(self):
69         """Returns the list of top-level blueprints for the extension.
70
71         These blueprints will be mounted at the url prefix given to
72         app.load_extension().
73
74         :rtype: list of flask.Blueprint objects.
75         """
76
77         from . import routes
78
79         return [
80             routes.blueprint,
81         ]
82
83     def setup_app(self, app):
84         from . import remote
85
86         self.remote = remote.API(
87             remote_url=app.config['SVNMAN_API_URL'],
88             username=app.config['SVNMAN_API_USERNAME'],
89             password=app.config['SVNMAN_API_PASSWORD'],
90         )
91
92     @property
93     def template_path(self):
94         return os.path.join(os.path.dirname(__file__), 'templates')
95
96     @property
97     def static_path(self):
98         return os.path.join(os.path.dirname(__file__), 'static')
99
100     def sidebar_links(self, project):
101         if not current_user.has_cap('svn-use'):
102             return ''
103         if not self.is_svnman_project(project):
104             return ''
105         return flask.render_template('svnman/sidebar.html', project=project)
106
107     @property
108     def has_project_settings(self) -> bool:
109         return current_user.has_cap('svn-use')
110
111     def project_settings(self, project: pillarsdk.Project, **template_args: dict) -> flask.Response:
112         """Renders the project settings page for this extension.
113
114         Set YourExtension.has_project_settings = True and Pillar will call this function.
115
116         :param project: the project for which to render the settings.
117         :param template_args: additional template arguments.
118         :returns: a Flask HTTP response
119         """
120
121         from .routes import project_settings
122
123         remote_url = current_app.config['SVNMAN_REPO_URL']
124
125         if self.is_svnman_project(project):
126             repo_id = project.extension_props[EXTENSION_NAME].repo_id
127             svn_url = urljoin(remote_url, repo_id)
128         else:
129             svn_url = ''
130             repo_id = ''
131
132         return project_settings(project,
133                                 svn_url=svn_url,
134                                 repo_id=repo_id,
135                                 remote_url=remote_url,
136                                 **template_args)
137
138     def is_svnman_project(self, project: pillarsdk.Project) -> bool:
139         """Checks whether the project is correctly set up for SVNman."""
140
141         try:
142             if not project.extension_props:
143                 return False
144         except AttributeError:
145             self._log.warning("is_svnman_project: Project url=%r doesn't have"
146                               " any extension properties.", project['url'])
147             if self._log.isEnabledFor(logging.DEBUG):
148                 import pprint
149                 self._log.debug('Project: %s', pprint.pformat(project.to_dict()))
150             return False
151
152         try:
153             pprops = project.extension_props[EXTENSION_NAME]
154         except KeyError:
155             return False
156
157         if pprops is None:
158             self._log.warning("is_svnman_project: Project url=%r doesn't have"
159                               " %s extension properties.", EXTENSION_NAME, project['url'])
160             return False
161
162         return bool(pprops.repo_id)
163
164     def create_repo(self, project: pillarsdk.Project, creator: str) -> str:
165         """Creates a SVN repository with a random ID attached to the project.
166
167         Saves the repository ID in the project. Is a no-op if the project
168         already has a Subversion repository.
169         """
170
171         from . import remote, exceptions
172
173         project_id = project['_id']
174         proj = project.to_dict()
175         eprops = proj.setdefault('extension_props', {}).setdefault(EXTENSION_NAME, {})
176
177         repo_id = eprops.get('repo_id')
178         if repo_id:
179             self._log.warning('project %s already has a Subversion repository %r',
180                               project_id, repo_id)
181             return repo_id
182
183         repo_info = remote.CreateRepo(
184             repo_id='',
185             project_id=str(project_id),
186             creator=creator,
187         )
188
189         for _ in range(100):
190             repo_info.repo_id = _random_id()
191             self._log.info('creating new repository, trying out %s', repo_info)
192             try:
193                 actual_repo_id = self.remote.create_repo(repo_info)
194             except exceptions.RepoAlreadyExists:
195                 self._log.info('repo_id=%r already exists, trying random other one',
196                                repo_info.repo_id)
197             else:
198                 break
199         else:
200             self._log.error('unable to find unique random repository ID, giving up')
201             raise ValueError('unable to find unique random repository ID, giving up')
202
203         self._log.info('created new Subversion repository: %s', repo_info)
204
205         # Update the project to include the repository ID.
206         eprops['repo_id'] = actual_repo_id
207         proj_utils.put_project(proj)
208
209         return actual_repo_id
210
211     def delete_repo(self, project_url: str, repo_id: str):
212         """Deletes an SVN repository and detaches it from the project."""
213
214         from . import remote, exceptions
215
216         proj = proj_utils.get_project(project_url)
217         project_id = proj['_id']
218         eprops = proj.setdefault('extension_props', {}).setdefault(EXTENSION_NAME, {})
219
220         proj_repo_id = eprops.get('repo_id')
221         if proj_repo_id != repo_id:
222             self._log.warning('project %s is linked to repo %r, not to %r, refusing to delete',
223                               project_id, proj_repo_id, repo_id)
224             raise ValueError()
225
226         self.remote.delete_repo(repo_id)
227         self._log.info('deleted Subversion repository %s', repo_id)
228
229         # Update the project to remove the repository ID.
230         eprops.pop('repo_id', None)
231         eprops.pop('access', None)
232         proj_utils.put_project(proj)
233
234     def svnman_projects(self, *, projection: dict = None):
235         """Returns projects with a Subversion repository.
236
237         :returns: {'_items': [proj, proj, ...], '_meta': Eve metadata}
238         """
239
240         import pillarsdk
241         from pillar.web.system_util import pillar_api
242
243         api = pillar_api()
244
245         # Find projects that have a repository.
246         params = {'where': {f'extension_props.{EXTENSION_NAME}.repo_id': {'$exists': 1}}}
247         if projection:
248             params['projection'] = projection
249
250         projects = pillarsdk.Project.all(params, api=api)
251         return projects
252
253
254
255 def _get_current_svnman() -> SVNManExtension:
256     """Returns the SVNMan extension of the current application."""
257
258     return flask.current_app.pillar_extensions[EXTENSION_NAME]
259
260
261 def _random_id(alphabet=string.ascii_letters + string.digits) -> str:
262     """Returns a random repository ID.
263
264     IDs start with a lowercase-letters-only prefix so that any
265     prefix-based subdivision on the server doesn't need to
266     distinguish between too many different prefixes. It's
267     a bit ugly to do that here, but at least it works 🐙
268     """
269     import random
270
271     prefix = ''.join([random.choice(string.ascii_lowercase) for _ in range(2)])
272     return prefix + ''.join([random.choice(alphabet) for _ in range(22)])
273
274
275 current_svnman: SVNManExtension = LocalProxy(_get_current_svnman)
276 """SVNMan extension of the current app."""