123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691 |
- # -*- coding: utf-8 -*-
- from __future__ import unicode_literals
-
- """Photo album models."""
-
- import os
- from flask import g
- import time
- import requests
- from PIL import Image
- import hashlib
- import random
-
- from rophako.settings import Config
- import rophako.jsondb as JsonDB
- from rophako.utils import sanitize_name, remote_addr
- from rophako.log import logger
-
- # Maps the friendly names of photo sizes with their pixel values from config.
- PHOTO_SCALES = dict(
- large=int(Config.photo.width_large),
- thumb=int(Config.photo.width_thumb),
- avatar=int(Config.photo.width_avatar),
- )
-
-
- def list_albums():
- """Retrieve a sorted list of the photo albums."""
- index = get_index()
- result = []
-
- # Missing settings?
- if not "settings" in index:
- index["settings"] = dict()
-
- for album in index["album-order"]:
- if not album in index["settings"]:
- # Need to initialize its settings.
- index["settings"][album] = dict(
- format="classic",
- description="",
- )
- write_index(index)
-
- cover = index["covers"][album]
- pic = index["albums"][album][cover]["thumb"]
- result.append(dict(
- name=album,
- format=index["settings"][album]["format"],
- description=index["settings"][album]["description"],
- cover=pic,
- data=index["albums"][album],
- ))
-
- return result
-
-
- def get_album(name):
- """Get details about an album."""
- index = get_index()
-
- if not name in index["albums"]:
- return None
-
- album = index["albums"][name]
- cover = index["covers"][name]
-
- return dict(
- name=name,
- format=index["settings"][name]["format"],
- description=index["settings"][name]["description"],
- cover=album[cover]["thumb"],
- )
-
-
- def rename_album(old_name, new_name):
- """Rename an existing photo album.
-
- Returns True on success, False if the new name conflicts with another
- album's name."""
- old_name = sanitize_name(old_name)
- new_name = sanitize_name(new_name)
- index = get_index()
-
- # New name is unique?
- if new_name in index["albums"]:
- logger.error("Can't rename album: new name already exists!")
- return False
-
- def transfer_key(obj, old_key, new_key):
- # Reusable function to do a simple move on a dict key.
- obj[new_key] = obj[old_key]
- del obj[old_key]
-
- # Simple moves.
- transfer_key(index["albums"], old_name, new_name)
- transfer_key(index["covers"], old_name, new_name)
- transfer_key(index["photo-order"], old_name, new_name)
- transfer_key(index["settings"], old_name, new_name)
-
- # Update the photo -> album maps.
- for photo in index["map"]:
- if index["map"][photo] == old_name:
- index["map"][photo] = new_name
-
- # Fix the album ordering.
- new_order = list()
- for name in index["album-order"]:
- if name == old_name:
- name = new_name
- new_order.append(name)
- index["album-order"] = new_order
-
- # And save.
- write_index(index)
- return True
-
-
- def list_photos(album):
- """List the photos in an album."""
- album = sanitize_name(album)
- index = get_index()
-
- if not album in index["albums"]:
- return None
-
- result = []
- for key in index["photo-order"][album]:
- data = index["albums"][album][key]
- result.append(dict(
- key=key,
- data=data,
- ))
-
- return result
-
-
- def photo_exists(key):
- """Query whether a photo exists in the album."""
- index = get_index()
- return key in index["map"]
-
-
- def get_photo(key):
- """Look up a photo by key. Returns None if not found."""
- index = get_index()
- photo = None
- if key in index["map"]:
- album = index["map"][key]
- if album in index["albums"] and key in index["albums"][album]:
- photo = index["albums"][album][key]
- else:
- # The map is wrong!
- logger.error("Photo album map is wrong; key {} not found in album {}!".format(key, album))
- del index["map"][key]
- write_index(index)
-
- if not photo:
- return None
-
- # Inject additional information about the photo:
- # What position it's at in the album, how many photos total, and the photo
- # IDs of its siblings.
- siblings = index["photo-order"][album]
- i = 0
- for pid in siblings:
- if pid == key:
- # We found us! Find the siblings.
- sprev, snext = None, None
- if i == 0:
- # We're the first photo. So previous is the last!
- sprev = siblings[-1]
- if len(siblings) > i+1:
- snext = siblings[i+1]
- else:
- snext = key
- elif i == len(siblings)-1:
- # We're the last. So next is first!
- sprev = siblings[i-1]
- snext = siblings[0]
- else:
- # Right in the middle.
- sprev = siblings[i-1]
- snext = siblings[i+1]
-
- # Inject.
- photo["album"] = album
- photo["position"] = i+1
- photo["siblings"] = len(siblings)
- photo["previous"] = sprev
- photo["next"] = snext
- i += 1
-
- return photo
-
-
- def get_image_dimensions(pic):
- """Use PIL to get the image's true dimensions."""
- filename = os.path.join(Config.photo.root_private, pic["large"])
- img = Image.open(filename)
- return img.size
-
-
- # def update_photo(album, key, data):
- # """Update photo meta-data in the album."""
- # index = get_index()
-
- # if not album in index["albums"]:
- # index["albums"][album] = {}
- # if not key in index["albums"][album]:
- # index["albums"][album][key] = {}
-
- # # Update!
- # index["albums"][album][key].update(data)
- # write_index(index)
-
-
- def crop_photo(key, x, y, length):
- """Change the crop coordinates of a photo and re-crop it."""
- index = get_index()
- if not key in index["map"]:
- raise Exception("Can't crop photo: doesn't exist!")
-
- album = index["map"][key]
-
- # Sanity check.
- if not album in index["albums"]:
- raise Exception("Can't find photo in album!")
-
- logger.debug("Recropping photo {}".format(key))
-
- # Delete all the images except the large one.
- for size in ["thumb", "avatar"]:
- pic = index["albums"][album][key][size]
- logger.debug("Delete {} size: {}".format(size, pic))
- os.unlink(os.path.join(Config.photo.root_private, pic))
-
- # Regenerate all the thumbnails.
- large = index["albums"][album][key]["large"]
- source = os.path.join(Config.photo.root_private, large)
- for size in ["thumb", "avatar"]:
- pic = resize_photo(source, size, crop=dict(
- x=x,
- y=y,
- length=length,
- ))
- index["albums"][album][key][size] = pic
-
- # Save changes.
- write_index(index)
-
-
- def set_album_cover(album, key):
- """Change the album's cover photo."""
- album = sanitize_name(album)
- index = get_index()
- logger.info("Changing album cover for {} to {}".format(album, key))
- if album in index["albums"] and key in index["albums"][album]:
- index["covers"][album] = key
- write_index(index)
- return
- logger.error("Failed to change album index! Album or photo not found.")
-
-
- def edit_photo(key, data):
- """Update a photo's data."""
- index = get_index()
- if not key in index["map"]:
- logger.warning("Tried to delete photo {} but it wasn't found?".format(key))
- return
-
- album = index["map"][key]
-
- logger.info("Updating data for the photo {} from album {}".format(key, album))
- index["albums"][album][key].update(data)
-
- write_index(index)
-
-
- def edit_album(album, data):
- """Update an album's settings (description, format, etc.)"""
- album = sanitize_name(album)
- index = get_index()
- if not album in index["albums"]:
- logger.error("Failed to edit album: not found!")
- return
-
- index["settings"][album].update(data)
- write_index(index)
-
-
- def rotate_photo(key, rotate):
- """Rotate a photo 90 degrees to the left or right."""
- photo = get_photo(key)
- if not photo: return
-
- # Degrees to rotate.
- degrees = None
- if rotate == "left":
- degrees = 90
- elif rotate == "right":
- degrees = -90
- else:
- degrees = 180
-
- new_names = dict()
- for size in ["large", "thumb", "avatar"]:
- fname = os.path.join(Config.photo.root_private, photo[size])
- logger.info("Rotating image {} by {} degrees.".format(fname, degrees))
-
- # Give it a new name.
- filetype = fname.split(".")[-1]
- outfile = random_name(filetype)
- new_names[size] = outfile
-
- img = Image.open(fname)
- img = img.rotate(degrees)
- img.save(os.path.join(Config.photo.root_private, outfile))
-
- # Delete the old name.
- os.unlink(fname)
-
- # Save the new image names.
- edit_photo(key, new_names)
-
-
- def delete_photo(key):
- """Delete a photo."""
- index = get_index()
- if not key in index["map"]:
- logger.warning("Tried to delete photo {} but it wasn't found?".format(key))
- return
-
- album = index["map"][key]
-
- logger.info("Completely deleting the photo {} from album {}".format(key, album))
- photo = index["albums"][album][key]
-
- # Delete all the images.
- for size in ["large", "thumb", "avatar"]:
- logger.info("Delete: {}".format(photo[size]))
- fname = os.path.join(Config.photo.root_private, photo[size])
- if os.path.isfile(fname):
- os.unlink(fname)
-
- # Delete it from the sort list.
- index["photo-order"][album].remove(key)
- del index["map"][key]
- del index["albums"][album][key]
-
- # Was this the album cover?
- if index["covers"][album] == key:
- # Try to pick a new one.
- if len(index["photo-order"][album]) > 0:
- index["covers"][album] = index["photo-order"][album][0]
- else:
- index["covers"][album] = ""
-
- # If the album is empty now too, delete it as well.
- if len(index["albums"][album].keys()) == 0:
- del index["albums"][album]
- del index["photo-order"][album]
- del index["covers"][album]
- index["album-order"].remove(album)
-
- write_index(index)
-
-
- def order_albums(order):
- """Reorder the albums according to the new order list."""
- index = get_index()
-
- # Sanity check, make sure all albums are included.
- if len(order) != len(index["album-order"]):
- logger.warning("Can't reorganize albums because the order lists don't match!")
- return None
-
- for album in index["album-order"]:
- if album not in order:
- logger.warning("Tried reorganizing albums, but {} was missing!".format(album))
- return None
-
- index["album-order"] = order
- write_index(index)
-
-
- def order_photos(album, order):
- """Reorder the photos according to the new order list."""
- index = get_index()
-
- if not album in index["albums"]:
- logger.warning("Album not found: {}".format(album))
- return None
-
- # Sanity check, make sure all albums are included.
- if len(order) != len(index["photo-order"][album]):
- logger.warning("Can't reorganize photos because the order lists don't match!")
- return None
-
- for key in index["photo-order"][album]:
- if key not in order:
- logger.warning("Tried reorganizing photos, but {} was missing!".format(key))
- return None
-
- index["photo-order"][album] = order
- write_index(index)
-
-
- def upload_from_pc(request):
- """Upload a photo from the user's filesystem.
-
- This requires the Flask `request` object. Returns a dict with the following
- keys:
-
- * success: True || False
- * error: if unsuccessful
- * photo: if successful
- """
-
- form = request.form
- count = 0
- status = None
- for upload in reversed(request.files.getlist("file")):
- count += 1
-
- # Make a temp filename for it.
- filetype = upload.filename.rsplit(".", 1)[-1]
- if not allowed_filetype(upload.filename):
- return dict(success=False, error="Unsupported file extension.")
-
- tempfile = "{}/rophako-photo-{}.{}".format(Config.site.tempdir, int(time.time()), filetype)
- logger.debug("Save incoming photo to: {}".format(tempfile))
- upload.save(tempfile)
-
- # All good so far. Process the photo.
- status = process_photo(form, tempfile)
- if not status["success"]:
- return status
-
- # Multi upload?
- if count > 1:
- status["multi"] = True
- else:
- status["multi"] = False
-
- return status
-
-
- def upload_from_www(form):
- """Upload a photo from the Internet.
-
- This requires the `form` object, but not necessarily Flask's. It just has to
- be a dict with the form keys for the upload.
-
- Returns the same structure as `upload_from_pc()`.
- """
-
- url = form.get("url")
- if not url or not allowed_filetype(url):
- return dict(success=False, error="Invalid file extension.")
-
- # Make a temp filename for it.
- filetype = url.rsplit(".", 1)[1]
- tempfile = "{}/rophako-photo-{}.{}".format(Config.site.tempdir, int(time.time()), filetype)
- logger.debug("Save incoming photo to: {}".format(tempfile))
-
- # Grab the file.
- try:
- data = requests.get(url).content
- except:
- return dict(success=False, error="Failed to get that URL.")
-
- fh = open(tempfile, "wb")
- fh.write(data)
- fh.close()
-
- # All good so far. Process the photo.
- return process_photo(form, tempfile)
-
-
- def process_photo(form, filename):
- """Formats an incoming photo."""
-
- # Resize the photo to each of the various sizes and collect their names.
- sizes = dict()
- for size in PHOTO_SCALES.keys():
- sizes[size] = resize_photo(filename, size)
-
- # Remove the temp file.
- os.unlink(filename)
-
- # What album are the photos going to?
- album = form.get("album", "")
- new_album = form.get("new-album", None)
- new_desc = form.get("new-description", None)
- if album == "" and new_album:
- album = new_album
-
- # Sanitize the name.
- album = sanitize_name(album)
- if album == "":
- logger.warning("Album name didn't pass sanitization! Fall back to default album name.")
- album = Config.photo.default_album
-
- # Make up a unique public key for this set of photos.
- key = random_hash()
- while photo_exists(key):
- key = random_hash()
- logger.debug("Photo set public key: {}".format(key))
-
- # Get the album index to manipulate ordering.
- index = get_index()
-
- # Update the photo data.
- if not album in index["albums"]:
- index["albums"][album] = {}
- if not "settings" in index:
- index["settings"] = dict()
- if not album in index["settings"]:
- index["settings"][album] = {
- "format": "classic",
- "description": new_desc,
- }
-
- index["albums"][album][key] = dict(
- ip=remote_addr(),
- author=g.info["session"]["uid"],
- uploaded=int(time.time()),
- caption=form.get("caption", ""),
- description=form.get("description", ""),
- **sizes
- )
-
- # Maintain a photo map to album.
- index["map"][key] = album
-
- # Add this pic to the front of the album.
- if not album in index["photo-order"]:
- index["photo-order"][album] = []
- index["photo-order"][album].insert(0, key)
-
- # If this is a new album, add it to the front of the album ordering.
- if not album in index["album-order"]:
- index["album-order"].insert(0, album)
-
- # Set the album cover for a new album.
- if not album in index["covers"] or len(index["covers"][album]) == 0:
- index["covers"][album] = key
-
- # Save changes to the index.
- write_index(index)
-
- return dict(success=True, photo=key)
-
-
- def allowed_filetype(filename):
- """Query whether the file extension is allowed."""
- return "." in filename and \
- filename.rsplit(".", 1)[1].lower() in ['jpeg', 'jpe', 'jpg', 'gif', 'png']
-
-
- def resize_photo(filename, size, crop=None):
- """Resize a photo from the target filename into the requested size.
-
- Optionally the photo can be cropped with custom parameters.
- """
-
- # Find the file type.
- filetype = filename.rsplit(".", 1)[1]
- if filetype == "jpeg": filetype = "jpg"
-
- # Open the image.
- img = Image.open(filename)
-
- # Make up a unique filename.
- outfile = random_name(filetype)
- target = os.path.join(Config.photo.root_private, outfile)
- logger.debug("Output file for {} scale: {}".format(size, target))
-
- # Get the image's dimensions.
- orig_width, orig_height = img.size
- new_width = PHOTO_SCALES[size]
- logger.debug("Original photo dimensions: {}x{}".format(orig_width, orig_height))
-
- # For the large version, only scale it, don't crop it.
- if size == "large":
- # Do we NEED to scale it?
- if orig_width <= new_width:
- logger.debug("Don't need to scale down the large image!")
- img.save(target)
- return outfile
-
- # Scale it down.
- ratio = float(new_width) / float(orig_width)
- new_height = int(float(orig_height) * float(ratio))
- logger.debug("New image dimensions: {}x{}".format(new_width, new_height))
- img = img.resize((new_width, new_height), Image.ANTIALIAS)
- img.save(target)
- return outfile
-
- # For all other versions, crop them into a square.
- x, y, length = 0, 0, 0
-
- # Use 0,0 and find the shortest dimension for the length.
- if orig_width > orig_height:
- length = orig_height
- else:
- length = orig_width
-
- # Did they give us crop coordinates?
- if crop is not None:
- x = crop["x"]
- y = crop["y"]
- if crop["length"] > 0:
- length = crop["length"]
-
- # Adjust the coords if they're impossible.
- if x < 0:
- logger.warning("X-Coord is less than 0; fixing!")
- x = 0
- if y < 0:
- logger.warning("Y-Coord is less than 0; fixing!")
- y = 0
- if x > orig_width:
- logger.warning("X-Coord is greater than image width; fixing!")
- x = orig_width - length
- if x < 0: x = 0
- if y > orig_height:
- logger.warning("Y-Coord is greater than image height; fixing!")
- y = orig_height - length
- if y < 0: y = 0
-
- # Make sure the crop box fits.
- if x + length > orig_width:
- diff = x + length - orig_width
- logger.warning("Crop box is outside the right edge of the image by {}px; fixing!".format(diff))
- length -= diff
- if y + length > orig_height:
- diff = y + length - orig_height
- logger.warning("Crop box is outside the bottom edge of the image by {}px; fixing!".format(diff))
- length -= diff
-
- # Do we need to scale?
- if new_width == length:
- logger.debug("Image doesn't need to be cropped or scaled!")
- img.save(target)
- return outfile
-
- # Crop to the requested box.
- logger.debug("Cropping the photo")
- img = img.crop((x, y, x+length, y+length))
-
- # Scale it to the proper dimensions.
- img = img.resize((new_width, new_width), Image.ANTIALIAS)
- img.save(target)
- return outfile
-
-
- def get_index():
- """Get the photo album index, or a new empty DB if it doesn't exist."""
- if JsonDB.exists("photos/index"):
- return JsonDB.get("photos/index")
-
- return {
- "albums": {}, # Album data
- "map": {}, # Map photo keys to albums
- "covers": {}, # Album cover photos
- "photo-order": {}, # Ordering of photos in albums
- "album-order": [], # Ordering of albums themselves
- }
-
-
- def write_index(index):
- """Save the index back to the DB."""
- return JsonDB.commit("photos/index", index)
-
-
- def random_name(filetype):
- """Get a random available file name to save a new photo."""
- filetype = filetype.lower()
- outfile = random_hash() + "." + filetype
- while os.path.isfile(os.path.join(Config.photo.root_private, outfile)):
- outfile = random_hash() + "." + filetype
- return outfile
-
-
- def random_hash():
- """Get a short random hash to use as the base name for a photo."""
- md5 = hashlib.md5()
- md5.update(str(random.randint(0, 1000000)).encode("utf-8"))
- return md5.hexdigest()[:8]
|