A Python content management system designed for kirsle.net featuring a blog, comments and photo albums. https://rophako.kirsle.net/
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

691 lines
20 KiB

# -*- coding: utf-8 -*-
"""Photo album models."""
import os
from flask import g, request
import time
import requests
from PIL import Image
import hashlib
import random
import config
import rophako.jsondb as JsonDB
from rophako.utils import sanitize_name
from rophako.log import logger
# Maps the friendly names of photo sizes with their pixel values from config.
PHOTO_SCALES = dict(
large=config.PHOTO_WIDTH_LARGE,
thumb=config.PHOTO_WIDTH_THUMB,
avatar=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()
result = []
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)
newname = 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.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.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=request.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]