~3v1n0/bileto/ppa-packages-link

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
"""Bileto Public API, v1.

This file defines behaviors for the following API endpoints:

/:                  Homepage
/v1/tickets:        Listing & creating tickets.
/v1/ticket/<id>:    Retreiving a single ticket.
/v1/silo/<name>:    Retreiving a ticket in one silo.
/v1/user/<name>:    Retreiving tickets created by a user.
/v1/publishable:    Listing requests requiring trainguard action.
/v1/failures:       Listing requests in various fail states.
/v1/metadata:       Basic info needed for display in frontend.
/v1/comments:       Searchable & paginated comment list.
/v1/comment:        Post new comments
/v1/errors:         Dump error log (authenticated users only).
"""

from contextlib import suppress
from urllib.parse import urlencode

from sqlalchemy import func, desc
from flask import Response, abort, jsonify, redirect, request, session

from bileto.lplib import lp
from bileto.app import app, db
from bileto.settings import Config
from bileto.json import BiletoJSONEncoder
from bileto.models import HIDDEN, Request, Comment


LOGFILE = '/var/log/bileto.log'
HID = [Request.status != hide for hide in HIDDEN]
LC = func.lower
FAILURES = ('can\'t', 'caught', 'error', 'exception',
            'fail', 'needs', 'unacceptable')
EXPAND = {'search'}
CONTRACT = EXPAND | {'comments', 'merge_proposals'}
ADMIN = {
    'canonical-platform-qa',
    'ci-train-ppa-service',
    'ubuntu-core-dev',
}
USER = ADMIN | {'ci-train-users'}


def assert_json():
    """Require that JSON has been given to us."""
    if not request.json:
        abort(400)


def check_api_token():
    """Confirm if the appropriate API token has been submitted."""
    token = Config['api.token']
    return token and token == request.args.get('token')


def has_team(teams):
    """Confirm if the user is a member of one of the given set of teams."""
    return teams & set(session.get('teams') or set())


def authenticate():
    """Prevent API from being accessed without correct authentication."""
    if not (has_team(USER) or check_api_token()):
        abort(401)


def safe_int(val):
    """Convert to an int without exceptions."""
    try:
        return int(val)
    except (ValueError, TypeError):
        return 0


def diff_dicts(alpha, beta):
    """Identify which keys differ between dicts.

    Only considers keys from the first dict.
    """
    fields = {key for key, val in alpha.items() if beta.get(key) != val}
    fields -= set(Request.verboten)
    fields -= {'author', 'job_log'}
    return fields


def audit(status, request_id, author='bileto-bot', job_log=None, **kwargs):
    """Create an audit record of something that happened."""
    comment = Comment(
        log_url=job_log or '/#/ticket/{}'.format(request_id),
        published_versions=kwargs.get('published_versions', ''),
        siloname=kwargs.get('siloname', ''),
        request_id=request_id,
        author=author,
        text=status)
    db.session.add(comment)


def do_pagination(args, pages, page, limit):
    """Calculate some pagination magic."""
    remaining = max(pages.total - (page * limit), 0)
    next_, prev = '', ''
    if remaining:
        args.update(page=page + 1, limit=limit)
        next_ = urlencode(args)
    if page > 1:
        args.update(page=page - 1, limit=limit)
        prev = urlencode(args)
    return next_, prev


def return_reqs(reqs, **kwargs):
    """Return JSONified requests."""
    assert None not in reqs
    BiletoJSONEncoder.hide = CONTRACT if len(reqs) > 1 else EXPAND
    nick = session.get('nickname', '')
    kwargs.update(
        instance=Config['instance.id'],
        ircnick=session.get('ircnick', nick),
        admin=sorted(has_team(ADMIN)),
        requests=reqs,
        nickname=nick,
        teams=sorted(has_team(USER)),
        assigned=Request.query.filter(
            Request.siloname.contains('landing'), *HID).count(),
    )
    return jsonify(kwargs)


@app.route('/', methods=['GET'])
def index_root():
    """Deliver the front page."""
    return app.send_static_file('index.html')


@app.route('/static/dashboard.html', methods=['GET'])
def dashboard_redirect():
    """Redirect to the front page."""
    return redirect('/')


@app.route('/v1/tickets', methods=['POST'])
def create_or_update():
    """Inject given JSON into db; either creating or updating a record."""
    authenticate()
    assert_json()
    requestid = request.json.get('request_id')
    success = 200 if requestid else 201
    if not requestid:
        req = Request()
        db.session.add(req)
        request.json['creator'] = session['nickname']
    else:
        try:
            req = Request.query.get(requestid)
            assert req.update  # Raise AttributeError here rather than below
        except Exception:
            db.session.rollback()
            abort(400)
        nick = session.get('nickname')
        diff = diff_dicts(request.json, dict(req))
        if 'status' in diff:
            audit(**request.json)
        else:
            kwargs = dict(request.json)
            kwargs['author'] = nick or kwargs.get('author') or 'bileto-bot'
            for key in diff:
                msg = '{}: {}'.format(key, request.json[key])
                kwargs['status'] = msg if len(msg) < 100 else 'Updated ' + key
                audit(**kwargs)
    req.update(**request.json)
    db.session.commit()
    return jsonify(dict(request_id=req.request_id)), success


@app.route('/v1/tickets', methods=['GET'])
def list_requests():
    """Get list, either hiding completed, or showing search results."""
    args = dict(request.args.items())
    args.pop('date', None)  # FIXME: Allow searching by date.
    limit = max(safe_int(args.pop('limit', None)), 0) or 1000
    page = max(safe_int(args.pop('page', None)), 1)
    active = args.pop('active_only', None)
    if args:
        try:
            filters = [LC(getattr(Request, field)).contains(LC(value))
                       for field, value in args.items()]
            filters += HID if active is not None else []
        except AttributeError:
            abort(400)
    else:
        filters = HID
    reqs = (Request
            .query.filter(*filters)
            .order_by(desc(Request.request_id))
            .paginate(page, limit, False))
    next_, prev = do_pagination(args, reqs, page, limit)
    return return_reqs(reqs.items, next=next_, prev=prev)


@app.route('/v1/publishable', methods=['GET'])
@app.route('/v1/trainguards', methods=['GET'])
def list_requests_requiring_action():
    """Show requests that require trainguard action."""
    return return_reqs(
        [req for req in
         Request.query.filter(
             Request.status.contains('Successfully built') |
             Request.status.contains('not authorized to upload') |
             Request.status.contains('need manual ACKing')).all()
         if req.publishable()])


@app.route('/v1/failures', methods=['GET'])
def list_requests_in_failed_states():
    """Show requests that are in failure states."""
    reqs = set()
    for word in FAILURES:
        reqs.update(Request.query.filter(
            LC(Request.status).contains(word)).all())
    return return_reqs(sorted(reqs))


@app.route('/v1/silo/<distro>/<siloname>', methods=['GET'])
@app.route('/v1/silo/<siloname>', methods=['GET'])
def get_by_siloname(siloname, distro=None):
    """Search requests by siloname, hiding landed/abandoned."""
    if distro:
        siloname = '{}/{}'.format(distro, siloname)
    reqs = Request.query.filter(
        LC(Request.siloname).contains(LC(siloname)), *HID).all()
    return return_reqs(sorted(
        reqs, reverse=True,
        key=lambda req: (req.comments[0:1] or [req])[0].date))


@app.route('/v1/user/<nickname>', methods=['GET'])
def get_by_user(nickname):
    """Search outstanding requests by requesting username."""
    return return_reqs(
        Request.query.filter(Request.landers.contains(nickname), *HID).all())


@app.route('/v1/ticket/<int:requestid>', methods=['GET'])
def get_request(requestid):
    """Get a single request by id."""
    req = Request.query.get(requestid)
    if not req:
        abort(404)
    return return_reqs([req])


@app.route('/v1/create', methods=['GET'])
def create_new_request():
    """Give the user a dummy request to populate."""
    authenticate()
    return return_reqs([Request.new()])


@app.route('/v1/metadata', methods=['GET'])
def documentation():
    """Return an assortment of information used by the frontend."""
    series = lp.active_series()
    team = Config['ppa.team']
    return jsonify(dict(
        docs=Request.docs(),
        titles=Request.titles(),
        datalists=dict(
            lander_signoff=['', 'Approved', 'Failed'],
            qa_signoff=['N/A', 'Required', 'Ready', 'Approved', 'Failed'],
            distribution=['ubuntu'],
            series=['xenial+vivid'] + series,
            dest=['', '{}/ubuntu/stable-phone-overlay'.format(team)],
            sync_request=['ubuntu,' + s for s in series] + [
                '0',
                'stable-overlay,vivid',
                'ubuntu-rtm,14.09',
                'ppa:~team/ubuntu/ppa,vivid',
            ],
            status=(
                'New',
            ) + HIDDEN),
        jenkins=Config['jenkins.url'],
        ppa_team=team,
    ))


@app.route('/v1/comment', methods=['POST'])
def post_comment():
    """Create a new comment."""
    authenticate()
    assert_json()
    if not request.json.get('text'):
        abort(400)
    nick = session.get('nickname')
    if nick:
        request.json['author'] = nick
    comment = Comment(**request.json)
    db.session.add(comment)
    db.session.commit()
    return jsonify(comment), 201


@app.route('/v1/comments', methods=['GET'])
def list_comments():
    """Return comments matching various filters & paginated."""
    args = dict(request.args.items())
    args.pop('date', None)  # FIXME: Allow searching by date.
    limit = max(safe_int(args.pop('limit', None)), 0) or 100
    page = max(safe_int(args.pop('page', None)), 1)
    filters = []
    if args:
        try:
            filters = [LC(getattr(Comment, field)).contains(LC(value))
                       for field, value in args.items()]
        except AttributeError:
            abort(400)
    comments = (Comment
                .query.filter(*filters)
                .order_by(desc(Comment.id))
                .paginate(page, limit, False))
    next_, prev = do_pagination(args, comments, page, limit)
    return jsonify(dict(comments=comments.items, next=next_, prev=prev))


@app.route('/v1/errors', methods=['GET'])
def dump_errors():
    """Expose the error log only to authenticated users."""
    authenticate()
    with suppress(FileNotFoundError):
        with open(LOGFILE, encoding='utf-8') as errors:
            return Response(errors.read(), mimetype='text/plain')