Source

ophot / ophot / api / photos.py

Full commit
  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
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
# Copyright 2011 Jeffrey Finkelstein
#
# This file is part of Ophot.
#
# Ophot is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ophot is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with Ophot.  If not, see <http://www.gnu.org/licenses/>.
"""Provides REST/JSON routes for reading and writing photos."""
# imports for compatibility with future python versions
from __future__ import absolute_import
from __future__ import division

# imports from built-in modules
import hashlib
import imghdr
import os
import os.path

# imports from third-party modules
from flask import g
from flask import jsonify
from flask import request
from flask import make_response
from flask.views import MethodView
from PIL import Image

# imports from this application
from ..app import app
from ..helpers import select_single
from .helpers import to_photo_dict
from .helpers import jsonify_status_code
from .helpers import require_logged_in
from .queries import Q_ADD_PHOTO
from .queries import Q_DELETE_PHOTO
from .queries import Q_GET_CATEGORIES
from .queries import Q_GET_LAST_DISP_POS
from .queries import Q_GET_PHOTO
from .queries import Q_GET_PHOTO_BY_DISPLAYPOS
from .queries import Q_GET_PHOTO_BY_PROPERTIES
from .queries import Q_GET_PHOTO_DISPLAYPOS
from .queries import Q_GET_PHOTOS
from .queries import Q_GET_PHOTOS_BY_CAT
from .queries import Q_GET_PHOTO_IDS_BY_CAT
from .queries import Q_UPDATE_PHOTO_CATEGORY
from .queries import Q_UPDATE_PHOTO_DISPLAYPOS


IMAGE_QUALITY = 100
"""The quality (as an integer between 1 and 100, inclusive) of the JPEG image
saved after the used uploads an image file to the application.

A value greater than 95 is not recommended by the Python Image Library; see
here_ for more information.

.. _here: http://www.pythonware.com/library/pil/handbook/format-jpeg.htm

"""


def _get_last_display_position(categoryid):
    """Helper method which returns the index in the display sequence of the
    last photo in the specified category.

    Might return ``None``.

    """
    # sometimes returns None
    return select_single(Q_GET_LAST_DISP_POS, (categoryid, ))


def _category_exists(categoryid):
    """Returns ``True`` if and only if the database contains a category with
    the specified ID (an integer).

    """
    return categoryid in (c[0] for c in
                          g.db.execute(Q_GET_CATEGORIES).fetchall())


def _photo_exists(photoid):
    """Returns ``True`` if and only if the database contains a photo with the
    specified ID (an integer).

    """
    return photoid in (r[0] for r in g.db.execute(Q_GET_PHOTOS).fetchall())


class SplashPhotoAPI(MethodView):
    """Routes for RESTful access to the splash page background photo."""

    def get(self):
        """Gets the filename of the splash photo.

        The JSON response will look like::

          { "filename": "static/photos/splash.jpg" }

        """
        return jsonify(filename=app.config['SPLASH_PHOTO_FILENAME'])

    def post(self):
        """Updates the current splash photo.

        The request argument is ``photo``, which is the file to set as the
        splash page background photo. The response

        :form photo: The photo to set as the splash page background photo.
        :type photo: file
        :statuscode 204: If the photo was set.
        :statuscode 400: If no photo is specified.
        :statuscode 401: If the user is not logged in.
        :statuscode 415: If the photo file is not in an accepted format.

        """
        require_logged_in()
        if 'photo' not in request.files:
            return jsonify_status_code(400, message='Must specify file')
        photo = request.files['photo']
        imagetype = imghdr.what(photo.stream)
        if imagetype not in app.config['ALLOWED_EXTENSIONS']:
            message = 'File must be in supported format'
            return jsonify_status_code(415, message=message,
                                       foundtype=imagetype)
        # HACK see the comment in AllPhotosAPI.post()
        filename = os.path.join(app.config['BASE_DIR'],
                                app.config['SPLASH_PHOTO_FILENAME'])
        img = Image.open(photo)
        # resize the photo, if necessary
        width = int(app.config['SPLASH_PHOTO_WIDTH'])
        height = int(app.config['SPLASH_PHOTO_HEIGHT'])
        if img.size[0] > width or img.size[1] > height:
            img = img.resize((width, height), Image.ANTIALIAS)
        img.save(filename, imagetype, quality=IMAGE_QUALITY)
        return make_response(None, 204)


class SinglePhotoAPI(MethodView):
    """Routes for RESTful access to individual photos."""

    @staticmethod
    def _update_photo_category(photoid, categoryid):
        """Updates the category of the photo with the specified ID, and assigns
        the photo to the last display position.

        """
        position = (_get_last_display_position(categoryid) or 0) + 1
        g.db.execute(Q_UPDATE_PHOTO_CATEGORY, (categoryid, position, photoid))
        g.db.commit()

    @staticmethod
    def _update_photo_displaypos(photoid, displayposition):
        """Updates the display position of the photo with the specified ID, or
        swaps it if the specified display position is already occupied.

        """
        # get the ID of the photo which already exists at the requested display
        # position (if there is one)
        existing = select_single(Q_GET_PHOTO_BY_DISPLAYPOS,
                                 (displayposition, ))
        if existing:
            # move the photo that was already in that position to the old
            # position of the requested photo
            current_pos = select_single(Q_GET_PHOTO_DISPLAYPOS, (photoid, ))
            g.db.execute(Q_UPDATE_PHOTO_DISPLAYPOS, (current_pos, existing))
        # move the requested photo to the requested displayposition
        g.db.execute(Q_UPDATE_PHOTO_DISPLAYPOS, (displayposition, photoid))
        g.db.commit()

    def get(self, photoid):
        """Gets information for the photo with the specified ``photoid``.

        For example, if the request is :http:get:`/photos/42`, then the JSON
        response will look like this::

          {
            "id": 42,
            "displayposition": 1,
            "filename": "path/to/file",
            "categoryid": 2,
            "thumbnail": "path/to/thumbnail",
            "title": "My awesome photo"
          }

        :statuscode 404: If no photo exists with the specified ``photoid``.

        """
        result = g.db.execute(Q_GET_PHOTO, (photoid, )).fetchone()
        if result is None:
            return jsonify_status_code(404, message='Not found')
        return jsonify(to_photo_dict(result))

    def patch(self, photoid):
        """Updates the properties of the photo with the specified ``photoid``.

        There are two mutually exclusive request arguments. The two possible
        arguments are ``categoryid``, an integer which is the ID of the
        category to which the photo will be moved, and ``displayposition``,
        which is the integer representing the display position of this photo
        within its category. If either one is specified, the other cannot be
        specified. Otherwise, the response will be HTTP Error
        :http:statuscode:`400 Bad Request`.

        If the request would change the category of the photo, the display
        position of the photo is automatically set to one greater than the
        value of the greatest display position in the new category (or 1 if
        there are no other photos in that category).

        If the request would change the display position to a display position
        which is already claimed by some other photo, this will 'swap' the
        display positions of the two photos.

        For example, if the input is::

          { "displayposition": 8 }

        then the JSON response will look like this::

          {
            "id": 42,
            "displayposition": 8,
            "filename": "path/to/file",
            "categoryid": 2
          }

        :form categoryid: The ID of the new category to assign to this photo.
        :type categoryid: int
        :form displayposition: The new display position within the current
                               category to assign to this photo.
        :type displayposition: int
        :statuscode 400: If both `categoryid` and `displayposition` are
                         specified as form parameters.
        :statuscode 400: If no photo exists with the specified `id`.
        :statuscode 400: If `categoryid` is specified but no such category
                         exists.
        :statuscode 401: If the user is not logged in.

        """
        require_logged_in()
        if not _photo_exists(photoid):
            message = 'No such photo exists'
            return jsonify_status_code(400, message=message)
        change_category = 'categoryid' in request.form
        change_position = 'displayposition' in request.form
        if change_category and change_position:
            message = 'Cannot specify both categoryid and displayposition'
            return jsonify_status_code(400, message=message)
        if change_category:
            categoryid = int(request.form['categoryid'])
            if not _category_exists(categoryid):
                # TODO make error messages module-level constants
                message = 'No such category exists'
                return jsonify_status_code(400, message=message)
            self._update_photo_category(photoid, categoryid)
        if change_position:
            displayposition = int(request.form['displayposition'])
            self._update_photo_displaypos(photoid, displayposition)
        # TODO is it correct to make another read from the database to get the
        # updated data, or should we just assume that the updated data is
        # there?
        return self.get(photoid)

    def delete(self, photoid):
        """Deletes the photo with the specified ``photoid``.

        :statuscode 204: If the photo was deleted.
        :statuscode 400: If no photo exists with the specified ID.
        :statuscode 401: If the user is not logged in.

        """
        require_logged_in()
        if not _photo_exists(photoid):
            message = 'No such photo exists'
            return jsonify_status_code(400, message=message)
        g.db.execute(Q_DELETE_PHOTO, (photoid, ))
        g.db.commit()
        return make_response(None, 204)


class AllPhotosAPI(MethodView):
    """Routes for RESTful access to all photos."""

    @staticmethod
    def _to_thumbnail_filename(filename):
        """Appends '-thumb' just before the file extension of the specified
        ``filename``.

        """
        return '-thumb.'.join(filename.rsplit('.', 1))

    @staticmethod
    def _generate_filename(image, directory, extension):
        """Generates filename to which to save a file uploaded by the user.

        ``image`` is the Image object created from the file uploaded by the
        user. ``directory`` is the directory in which to save the
        file. ``extension`` is the file extension under which to save the
        image.

        """
        digest = hashlib.md5(image.tostring()).hexdigest()
        return os.path.join(directory, '{0}.{1}'.format(digest, extension))

    def get(self):
        """Returns information for every photo in the database.

        The JSON response will look like this::

          {
            "items":
              [
                {
                  "id": 42,
                  "displayposition": 1,
                  "filename": "path/to/file42",
                  "categoryid": 2
                },
                {
                  "id": 43,
                  "displayposition": 2,
                  "filename": "path/to/file43",
                  "categoryid": 2
                }
              ]
          }
        """
        result = g.db.execute(Q_GET_PHOTOS).fetchall()
        return jsonify(items=[to_photo_dict(row) for row in result])

    def post(self):
        """Adds a new photo to a category.

        Request arguments are ``photo``, which is the photo file to upload, and
        ``categoryid``, which is the ID of the category to which to add the
        photo.

        The only file formats currently accepted are PNG and JPEG.

        :form photo: The photo file data.
        :type photo: file
        :form categoryid: The ID of the category to which to add the photo.
        :type categoryid: int
        :statuscode 201: If the photo was successfully uploaded.
        :statuscode 400: If either of the request arguments was not specified.
        :statuscode 401: If the user is not logged in.
        :statuscode 415: If the photo is not in one of accepted formats
                         specified above.

        """
        require_logged_in()
        # create the directory which contains the photos if it doesn't exist
        photo_dir = os.path.join(app.config['BASE_DIR'],
                                 app.config['PHOTO_DIR'])
        if not os.path.exists(photo_dir):
            os.mkdir(photo_dir)
        if 'photo' not in request.files or 'categoryid' not in request.form:
            message = 'Must specify both file and categoryid'
            return jsonify_status_code(400, message=message)
        photo = request.files['photo']
        # guess the type of the photo
        imagetype = imghdr.what(photo.stream)
        if imagetype not in app.config['ALLOWED_EXTENSIONS']:
            message = 'File must be in supported format'
            return jsonify_status_code(415, message=message,
                                       foundtype=imagetype)
        img = Image.open(photo)
        # HACK long_filename is needed for using python to operate on files in
        # the filesystem. the shorter filename is needed for the webpage to
        # correctly link to image sources
        filename = self._generate_filename(img, app.config['PHOTO_DIR'],
                                           imagetype)
        long_filename = os.path.join(app.config['BASE_DIR'], filename)
        # resize and resave the image if necessary
        height = app.config['PHOTO_HEIGHT']
        if img.size[1] > height:
            # recall: "a // b" is equivalent to "floor(a / b)"
            width = (img.size[0] * height) // img.size[1]
            img = img.resize((width, height))
        # create the thumbnail of the image
        thumbnail_filename = self._to_thumbnail_filename(filename)
        thumbnail_long_filename = os.path.join(app.config['BASE_DIR'],
                                               thumbnail_filename)
        thumbnail = img.copy()
        thumbnail.thumbnail(app.config['THUMBNAIL_SIZE'], Image.ANTIALIAS)
        # save the image and the thumbnail
        img.save(long_filename, imagetype, quality=IMAGE_QUALITY)
        thumbnail.save(thumbnail_long_filename, imagetype)
        # get the category and display position to set for this photo
        categoryid = int(request.form['categoryid'])
        position = (_get_last_display_position(categoryid) or 0) + 1
        # if no title has been provided, use the empty string
        title = request.form.get('title', '')
        # add the photo to the database
        g.db.execute(Q_ADD_PHOTO, (position, filename, thumbnail_filename,
                                   title, categoryid))
        g.db.commit()
        # get the photo that we just added
        result = g.db.execute(Q_GET_PHOTO_BY_PROPERTIES, (filename, categoryid,
                                                          position))
        return jsonify_status_code(201, to_photo_dict(result.fetchone()))


class PhotosByCategoryAPI(MethodView):
    """Routes for RESTful access to photos organized by category."""

    def get(self, categoryid):
        """Returns information for every photo in the specified category,
        sorted by photo display position in ascending order.

        With no request query parameters, the JSON response will look like
        this::

          {
            "items":
              [
                {
                  "id": 40,
                  "displayposition": 1,
                  "filename": "path/to/file40",
                  "categoryid": 2
                  "thumbnail": "path/to/thumbnail40",
                  "title": "Liberty Leading the People"
                },
                {
                  "id": 50,
                  "displayposition": 2,
                  "filename": "path/to/file50",
                  "categoryid": 2
                  "thumbnail": "path/to/thumbnail50",
                  "title": "Louis XIV"
                }
              ]
          }

        If the ``pagesize`` request query parameter is specified, the returned
        array will be two-dimensional, with rows of size ``pagesize`` (except
        for the last row, which may have fewer than ``pagesize`` elements). For
        example, if category 2 contains five photos and the input is::

          { "pagesize": 3 }

        then the returned JSON will be::

          {
            "items":
              [
                [
                  {
                    "id": 40,
                    "displayposition": 1,
                    "filename": "path/to/file40",
                    "categoryid": 2,
                    "thumbnail": "path/to/file40-thumbnail",
                    "title": "Photo 40"
                  },
                  {
                    "id": 50,
                    "displayposition": 2,
                    "filename": "path/to/file50",
                    "categoryid": 2,
                    "thumbnail": "path/to/file50-thumbnail",
                    "title": "Photo 50"
                  },
                  {
                    "id": 60,
                    "displayposition": 3,
                    "filename": "path/to/file60",
                    "categoryid": 2,
                    "thumbnail": "path/to/file60-thumbnail",
                    "title": "Photo 60"
                  }
                ],
                [
                  {
                    "id": 70,
                    "displayposition": 4,
                    "filename": "path/to/file70",
                    "categoryid": 2,
                    "thumbnail": "path/to/file70-thumbnail",
                    "title": "Photo 70"
                  },
                  {
                    "id": 80,
                    "displayposition": 5,
                    "filename": "path/to/file80",
                    "categoryid": 2,
                    "thumbnail": "path/to/file80-thumbnail",
                    "title": "Photo 80"
                  }
                ]
              ]
          }

        ``pagesize`` must be a positive integer.

        :query pagesize: If this query parameter is specified, the returned
                         array of photos will be an array of arrays in which
                         each inner array has the specified size (except for
                         the last, which will have the remainder of the photos
                         and so may have fewer than the specified size).
        :type pagesize: int
        :statuscode 400: If ``pagesize`` is specified but is not a positive
                         integer.
        :statuscode 400: If no category exists with the specified ID.

        """
        if not _category_exists(categoryid):
            return jsonify_status_code(400, message='No such category')
        result = g.db.execute(Q_GET_PHOTOS_BY_CAT, (categoryid, ))
        photos = [to_photo_dict(row) for row in result.fetchall()]
        if 'pagesize' in request.args:
            inc = int(request.args['pagesize'])
            if inc <= 0:
                message = 'Pagesize must be a positive integer'
                return jsonify_status_code(400, message=message)
            photos = [photos[i:i + inc] for i in range(0, len(photos), inc)]
        return jsonify(items=photos)


class PhotosByCategoryOrderAPI(MethodView):
    """Routes for RESTful access to the order of photos in a specific category.

    """

    @staticmethod
    def _is_permutation(list1, list2):
        """Returns ``True`` if and only if ``list1`` is a permutation of
        ``list2``.

        """
        set1 = frozenset(list1)
        set2 = frozenset(list2)
        return (len(list1) == len(set1) == len(set2) == len(list2)
                and set1 == set2)

    @staticmethod
    def _current_order(categoryid):
        """Returns a list of the IDs of each of the photos in this category,
        sorted by display position.

        """
        result = g.db.execute(Q_GET_PHOTO_IDS_BY_CAT, (categoryid, ))
        return [int(row[0]) for row in result.fetchall()]

    @staticmethod
    def _update_order(neworder):
        """Updates the display positions of each of the photos whose IDs are
        given in the array ``neworder`` to be their indices in that list.

        This method commits the changes to the database after all display
        positions have been updated.

        Pre-condition: ``neworder`` is a permutation of the IDs of all the
        photos in a single category.

        """
        for displaypos, photoid in enumerate(neworder):
            g.db.execute(Q_UPDATE_PHOTO_DISPLAYPOS, (displaypos, photoid))
        g.db.commit()

    def get(self, categoryid):
        """Returns a list containing the ID of each photo in the given
        category, ordered by their display positions in ascending order.

        There are no request query parameters. The JSON response will look like
        this::

          { "order": [2, 7, 3, 10] }

        """
        return jsonify(order=self._current_order(categoryid))

    def patch(self, categoryid):
        """Updates the display position of all photos in the category with ID
        ``categoryid``.

        The sole request argument is ``order``, which is a string
        representation of a list of photo IDs (as integers) representing
        the new display positions of all the photos in this category. Such a
        string representation can be created in JavaScript by doing::

            var x_array = [1, 2, 3];
            x.toString();

        which evaluates to ``"1,2,3"``.

        :form order: String representation of integers which are the photo IDs
                     in the order in which the photos will be displayed on the
                     page.
        :type order: string
        :statuscode 204: If the display positions were successfully set.
        :statuscode 400: If ``order`` is not specified.
        :statuscode 400: If ``order`` is not a permutation of the IDs of the
                         photos in this category.
        :statuscode 401: If the user is not logged in.

        """
        require_logged_in()
        if 'order' not in request.form:
            message = "Must specify 'order', a list of photo IDs"
            return jsonify_status_code(400, message=message)
        # the new order
        asstring = request.form['order']
        neworder = [int(photoid) for photoid in asstring.split(',')]
        # the current order
        currentorder = self._current_order(categoryid)
        # ensure that the requested new ordering is a permutation
        if not self._is_permutation(neworder, currentorder):
            message = 'List must contain IDs for all photos in this category'
            return jsonify_status_code(400, message=message)
        # update the order and commit the changes to the database
        self._update_order(neworder)
        return make_response(None, 204)