app.py
Python script, ASCII text executable
1import json 2import os 3import mimetypes 4import flask 5import ruamel.yaml as yaml 6import sqlalchemy.dialects.postgresql 7from matplotlib.image import thumbnail 8 9import config 10import markdown 11 12from datetime import datetime 13from os import path 14from flask_sqlalchemy import SQLAlchemy 15from flask_bcrypt import Bcrypt 16from flask_migrate import Migrate, current 17from urllib.parse import urlencode 18from PIL import Image, ImageOps 19 20app = flask.Flask(__name__) 21bcrypt = Bcrypt(app) 22 23app.config["SQLALCHEMY_DATABASE_URI"] = config.DB_URI 24app.config["SECRET_KEY"] = config.DB_PASSWORD 25 26db = SQLAlchemy(app) 27migrate = Migrate(app, db) 28 29 30def make_thumbnail(pil_image): 31pil_image = ImageOps.exif_transpose(pil_image) 32pil_image.thumbnail(config.THUMBNAIL_SIZE) 33pil_image = pil_image.convert("RGB") 34return pil_image 35 36 37@app.template_filter("split") 38def split(value, separator=None, maxsplit=-1): 39return value.split(separator, maxsplit) 40 41 42@app.template_filter("median") 43def median(value): 44value = list(value) # prevent generators 45return sorted(value)[len(value) // 2] 46 47 48@app.template_filter("set") 49def set_filter(value): 50return set(value) 51 52 53@app.template_global() 54def modify_query(**new_values): 55args = flask.request.args.copy() 56for key, value in new_values.items(): 57args[key] = value 58 59return f"{flask.request.path}?{urlencode(args)}" 60 61 62@app.context_processor 63def default_variables(): 64return { 65"current_user": db.session.get(User, flask.session.get("username")), 66"site_name": config.SITE_NAME, 67"config": config, 68} 69 70 71with app.app_context(): 72class User(db.Model): 73username = db.Column(db.String(32), unique=True, nullable=False, primary_key=True) 74password_hashed = db.Column(db.String(60), nullable=False) 75admin = db.Column(db.Boolean, nullable=False, default=False, server_default="false") 76pictures = db.relationship("PictureResource", back_populates="author") 77joined_timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 78galleries = db.relationship("Gallery", back_populates="owner") 79galleries_joined = db.relationship("UserInGallery", back_populates="user") 80ratings = db.relationship("PictureRating", back_populates="user") 81 82def __init__(self, username, password): 83self.username = username 84self.password_hashed = bcrypt.generate_password_hash(password).decode("utf-8") 85 86@property 87def formatted_name(self): 88if self.admin: 89return self.username + "*" 90return self.username 91 92 93class Licence(db.Model): 94id = db.Column(db.String(64), primary_key=True) # SPDX identifier 95title = db.Column(db.UnicodeText, nullable=False) # the official name of the licence 96description = db.Column(db.UnicodeText, 97nullable=False) # brief description of its permissions and restrictions 98info_url = db.Column(db.String(1024), 99nullable=False) # the URL to a page with general information about the licence 100url = db.Column(db.String(1024), 101nullable=True) # the URL to a page with the full text of the licence and more information 102pictures = db.relationship("PictureLicence", back_populates="licence") 103free = db.Column(db.Boolean, nullable=False, 104default=False) # whether the licence is free or not 105logo_url = db.Column(db.String(1024), nullable=True) # URL to the logo of the licence 106pinned = db.Column(db.Boolean, nullable=False, 107default=False) # whether the licence should be shown at the top of the list 108 109def __init__(self, id, title, description, info_url, url, free, logo_url=None, 110pinned=False): 111self.id = id 112self.title = title 113self.description = description 114self.info_url = info_url 115self.url = url 116self.free = free 117self.logo_url = logo_url 118self.pinned = pinned 119 120 121class PictureLicence(db.Model): 122id = db.Column(db.Integer, primary_key=True, autoincrement=True) 123 124resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id")) 125licence_id = db.Column(db.String(64), db.ForeignKey("licence.id")) 126 127resource = db.relationship("PictureResource", back_populates="licences") 128licence = db.relationship("Licence", back_populates="pictures") 129 130def __init__(self, resource, licence): 131self.resource = resource 132self.licence = licence 133 134 135class Resource(db.Model): 136__abstract__ = True 137 138id = db.Column(db.Integer, primary_key=True, autoincrement=True) 139title = db.Column(db.UnicodeText, nullable=False) 140description = db.Column(db.UnicodeText, nullable=False) 141timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 142origin_url = db.Column(db.String(2048), 143nullable=True) # should be left empty if it's original or the source is unknown but public domain 144 145 146class PictureNature(db.Model): 147# Examples: 148# "photo", "paper-scan", "2d-art-photo", "sculpture-photo", "computer-3d", "computer-painting", 149# "computer-line-art", "diagram", "infographic", "text", "map", "chart-graph", "screen-capture", 150# "screen-photo", "pattern", "collage", "ai", and so on 151id = db.Column(db.String(64), primary_key=True) 152description = db.Column(db.UnicodeText, nullable=False) 153resources = db.relationship("PictureResource", back_populates="nature") 154 155def __init__(self, id, description): 156self.id = id 157self.description = description 158 159 160class PictureObjectInheritance(db.Model): 161parent_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 162primary_key=True) 163child_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 164primary_key=True) 165 166parent = db.relationship("PictureObject", foreign_keys=[parent_id], 167back_populates="child_links") 168child = db.relationship("PictureObject", foreign_keys=[child_id], 169back_populates="parent_links") 170 171def __init__(self, parent, child): 172self.parent = parent 173self.child = child 174 175 176class PictureObject(db.Model): 177id = db.Column(db.String(64), primary_key=True) 178description = db.Column(db.UnicodeText, nullable=False) 179 180child_links = db.relationship("PictureObjectInheritance", 181foreign_keys=[PictureObjectInheritance.parent_id], 182back_populates="parent") 183parent_links = db.relationship("PictureObjectInheritance", 184foreign_keys=[PictureObjectInheritance.child_id], 185back_populates="child") 186 187def __init__(self, id, description, parents): 188self.id = id 189self.description = description 190if parents: 191for parent in parents: 192db.session.add(PictureObjectInheritance(parent, self)) 193 194 195class PictureRegion(db.Model): 196# This is for picture region annotations 197id = db.Column(db.Integer, primary_key=True, autoincrement=True) 198json = db.Column(sqlalchemy.dialects.postgresql.JSONB, nullable=False) 199 200resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 201nullable=False) 202object_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), nullable=True) 203 204resource = db.relationship("PictureResource", backref="regions") 205object = db.relationship("PictureObject", backref="regions") 206 207def __init__(self, json, resource, object): 208self.json = json 209self.resource = resource 210self.object = object 211 212 213class PictureResource(Resource): 214# This is only for bitmap pictures. Vectors will be stored under a different model 215# File name is the ID in the picture directory under data, without an extension 216file_format = db.Column(db.String(64), nullable=False) # MIME type 217width = db.Column(db.Integer, nullable=False) 218height = db.Column(db.Integer, nullable=False) 219nature_id = db.Column(db.String(32), db.ForeignKey("picture_nature.id"), nullable=True) 220author_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 221author = db.relationship("User", back_populates="pictures") 222 223nature = db.relationship("PictureNature", back_populates="resources") 224 225replaces_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=True) 226replaced_by_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 227nullable=True) 228 229replaces = db.relationship("PictureResource", remote_side="PictureResource.id", 230foreign_keys=[replaces_id], back_populates="replaced_by", 231post_update=True) 232replaced_by = db.relationship("PictureResource", remote_side="PictureResource.id", 233foreign_keys=[replaced_by_id], post_update=True) 234 235copied_from_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 236nullable=True) 237copied_from = db.relationship("PictureResource", remote_side="PictureResource.id", 238backref="copies", foreign_keys=[copied_from_id]) 239 240licences = db.relationship("PictureLicence", back_populates="resource") 241galleries = db.relationship("PictureInGallery", back_populates="resource") 242ratings = db.relationship("PictureRating", back_populates="resource") 243 244def __init__(self, title, author, description, origin_url, licence_ids, mime, 245nature=None): 246self.title = title 247self.author = author 248self.description = description 249self.origin_url = origin_url 250self.file_format = mime 251self.width = self.height = 0 252self.nature = nature 253db.session.add(self) 254db.session.commit() 255for licence_id in licence_ids: 256joiner = PictureLicence(self, db.session.get(Licence, licence_id)) 257db.session.add(joiner) 258 259def put_annotations(self, json): 260# Delete all previous annotations 261db.session.query(PictureRegion).filter_by(resource_id=self.id).delete() 262 263for region in json: 264object_id = region["object"] 265picture_object = db.session.get(PictureObject, object_id) 266 267region_data = { 268"type": region["type"], 269"shape": region["shape"], 270} 271 272region_row = PictureRegion(region_data, self, picture_object) 273db.session.add(region_row) 274 275@property 276def average_rating(self): 277if not self.ratings: 278return None 279return db.session.query(db.func.avg(PictureRating.rating)).filter_by(resource=self).scalar() 280 281@property 282def rating_totals(self): 283all_ratings = db.session.query(PictureRating.rating).filter_by(resource=self) 284return {rating: all_ratings.filter_by(rating=rating).count() for rating in range(1, 6)} 285 286@property 287def stars(self): 288if not self.ratings: 289return 0 290average = self.average_rating 291whole_stars = int(average) 292partial_star = average - whole_stars 293 294return [100] * whole_stars + [int(partial_star * 100)] + [0] * (4 - whole_stars) 295 296 297class PictureInGallery(db.Model): 298id = db.Column(db.Integer, primary_key=True, autoincrement=True) 299resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 300nullable=False) 301gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) 302 303resource = db.relationship("PictureResource") 304gallery = db.relationship("Gallery") 305 306def __init__(self, resource, gallery): 307self.resource = resource 308self.gallery = gallery 309 310 311class UserInGallery(db.Model): 312id = db.Column(db.Integer, primary_key=True, autoincrement=True) 313username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 314gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) 315 316user = db.relationship("User") 317gallery = db.relationship("Gallery") 318 319def __init__(self, user, gallery): 320self.user = user 321self.gallery = gallery 322 323 324class Gallery(db.Model): 325id = db.Column(db.Integer, primary_key=True, autoincrement=True) 326title = db.Column(db.UnicodeText, nullable=False) 327description = db.Column(db.UnicodeText, nullable=False) 328pictures = db.relationship("PictureInGallery", back_populates="gallery") 329owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 330owner = db.relationship("User", back_populates="galleries") 331users = db.relationship("UserInGallery", back_populates="gallery") 332 333def __init__(self, title, description, owner): 334self.title = title 335self.description = description 336self.owner = owner 337 338 339class PictureRating(db.Model): 340id = db.Column(db.Integer, primary_key=True, autoincrement=True) 341resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=False) 342username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 343rating = db.Column(db.Integer, db.CheckConstraint("rating >= 1 AND rating <= 5"), 344nullable=False) 345 346resource = db.relationship("PictureResource", back_populates="ratings") 347user = db.relationship("User", back_populates="ratings") 348 349def __init__(self, resource, user, rating): 350self.resource = resource 351self.user = user 352self.rating = rating 353 354 355@app.route("/") 356def index(): 357return flask.render_template( 358"home.html", 359random_resources=PictureResource.query.filter_by(replaced_by=None).order_by(db.func.random()).limit(10).all(), 360recent_resources=PictureResource.query.filter_by(replaced_by=None).order_by(PictureResource.timestamp.desc()).limit(10).all(), 361recent_unannotated_resources=PictureResource.query.filter_by(replaced_by=None).filter(~PictureResource.regions.any()).order_by(PictureResource.timestamp.desc()).limit(10).all(), 362) 363 364 365@app.route("/info/") 366def usage_guide(): 367with open("help/usage.md") as f: 368return flask.render_template("help.html", content=markdown.markdown2html(f.read())) 369 370 371@app.route("/accounts/") 372def accounts(): 373return flask.render_template("login.html") 374 375 376@app.route("/login", methods=["POST"]) 377def login(): 378username = flask.request.form["username"] 379password = flask.request.form["password"] 380 381user = db.session.get(User, username) 382 383if user is None: 384flask.flash("This username is not registered.") 385return flask.redirect("/accounts") 386 387if not bcrypt.check_password_hash(user.password_hashed, password): 388flask.flash("Incorrect password.") 389return flask.redirect("/accounts") 390 391flask.flash("You have been logged in.") 392 393flask.session["username"] = username 394return flask.redirect("/") 395 396 397@app.route("/logout") 398def logout(): 399flask.session.pop("username", None) 400flask.flash("You have been logged out.") 401return flask.redirect("/") 402 403 404@app.route("/signup", methods=["POST"]) 405def signup(): 406username = flask.request.form["username"] 407password = flask.request.form["password"] 408 409if db.session.get(User, username) is not None: 410flask.flash("This username is already taken.") 411return flask.redirect("/accounts") 412 413if set(username) > set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"): 414flask.flash( 415"Usernames can only contain the Latin alphabet, digits, hyphens, and underscores.") 416return flask.redirect("/accounts") 417 418if len(username) < 3 or len(username) > 32: 419flask.flash("Usernames must be between 3 and 32 characters long.") 420return flask.redirect("/accounts") 421 422if len(password) < 6: 423flask.flash("Passwords must be at least 6 characters long.") 424return flask.redirect("/accounts") 425 426user = User(username, password) 427db.session.add(user) 428db.session.commit() 429 430flask.session["username"] = username 431 432flask.flash("You have been registered and logged in.") 433 434return flask.redirect("/") 435 436 437@app.route("/profile", defaults={"username": None}) 438@app.route("/profile/<username>") 439def profile(username): 440if username is None: 441if "username" in flask.session: 442return flask.redirect("/profile/" + flask.session["username"]) 443else: 444flask.flash("Please log in to perform this action.") 445return flask.redirect("/accounts") 446 447user = db.session.get(User, username) 448if user is None: 449flask.abort(404) 450 451return flask.render_template("profile.html", user=user) 452 453 454@app.route("/object/<id>") 455def has_object(id): 456object_ = db.session.get(PictureObject, id) 457if object_ is None: 458flask.abort(404) 459 460descendants_cte = ( 461db.select(PictureObject.id) 462.where(PictureObject.id == id) 463.cte(name="descendants_cte", recursive=True) 464) 465 466descendants_cte = descendants_cte.union_all( 467db.select(PictureObjectInheritance.child_id) 468.where(PictureObjectInheritance.parent_id == descendants_cte.c.id) 469) 470 471query = db.session.query(PictureResource).filter( 472PictureResource.regions.any( 473PictureRegion.object_id.in_( 474db.select(descendants_cte.c.id) 475) 476) 477) 478 479page = int(flask.request.args.get("page", 1)) 480per_page = int(flask.request.args.get("per_page", 16)) 481 482resources = query.paginate(page=page, per_page=per_page) 483 484return flask.render_template("object.html", object=object_, resources=resources, 485page_number=page, 486page_length=per_page, num_pages=resources.pages, 487prev_page=resources.prev_num, 488next_page=resources.next_num, PictureRegion=PictureRegion) 489 490 491@app.route("/upload") 492def upload(): 493if "username" not in flask.session: 494flask.flash("Log in to upload pictures.") 495return flask.redirect("/accounts") 496 497licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), 498Licence.title).all() 499 500types = PictureNature.query.all() 501 502return flask.render_template("upload.html", licences=licences, types=types) 503 504 505@app.route("/upload", methods=["POST"]) 506def upload_post(): 507title = flask.request.form["title"] 508description = flask.request.form["description"] 509origin_url = flask.request.form["origin_url"] 510author = db.session.get(User, flask.session.get("username")) 511licence_ids = flask.request.form.getlist("licence") 512nature_id = flask.request.form["nature"] 513 514if author is None: 515flask.abort(401) 516 517file = flask.request.files["file"] 518 519if not file or not file.filename: 520flask.flash("Select a file") 521return flask.redirect(flask.request.url) 522 523if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 524flask.flash("Only images are supported") 525return flask.redirect(flask.request.url) 526 527if not title: 528flask.flash("Enter a title") 529return flask.redirect(flask.request.url) 530 531if not description: 532description = "" 533 534if not nature_id: 535flask.flash("Select a picture type") 536return flask.redirect(flask.request.url) 537 538if not licence_ids: 539flask.flash("Select licences") 540return flask.redirect(flask.request.url) 541 542licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 543if not any(licence.free for licence in licences): 544flask.flash("Select at least one free licence") 545return flask.redirect(flask.request.url) 546 547resource = PictureResource(title, author, description, origin_url, licence_ids, 548file.mimetype, 549db.session.get(PictureNature, nature_id)) 550db.session.add(resource) 551db.session.commit() 552file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 553pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 554resource.width, resource.height = pil_image.size 555pil_image = make_thumbnail(pil_image) 556pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS) 557db.session.commit() 558 559if flask.request.form.get("annotations"): 560try: 561resource.put_annotations(json.loads(flask.request.form.get("annotations"))) 562db.session.commit() 563except json.JSONDecodeError: 564flask.flash("Invalid annotations") 565 566flask.flash("Picture uploaded successfully") 567 568return flask.redirect("/picture/" + str(resource.id)) 569 570 571@app.route("/picture/<int:id>/") 572def picture(id): 573resource = db.session.get(PictureResource, id) 574if resource is None: 575flask.abort(404) 576 577image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 578 579current_user = db.session.get(User, flask.session.get("username")) 580have_permission = current_user and (current_user == resource.author or current_user.admin) 581 582own_rating = None 583if current_user: 584own_rating = PictureRating.query.filter_by(resource=resource, user=current_user).first() 585 586return flask.render_template("picture.html", resource=resource, 587file_extension=mimetypes.guess_extension(resource.file_format), 588size=image.size, copies=resource.copies, 589have_permission=have_permission, own_rating=own_rating) 590 591 592@app.route("/picture/<int:id>/annotate") 593def annotate_picture(id): 594resource = db.session.get(PictureResource, id) 595if resource is None: 596flask.abort(404) 597 598current_user = db.session.get(User, flask.session.get("username")) 599if current_user is None: 600flask.abort(401) 601 602if resource.author != current_user and not current_user.admin: 603flask.abort(403) 604 605return flask.render_template("picture-annotation.html", resource=resource, 606file_extension=mimetypes.guess_extension(resource.file_format)) 607 608 609@app.route("/picture/<int:id>/put-annotations-form") 610def put_annotations_form(id): 611resource = db.session.get(PictureResource, id) 612if resource is None: 613flask.abort(404) 614 615current_user = db.session.get(User, flask.session.get("username")) 616if current_user is None: 617flask.abort(401) 618 619if resource.author != current_user and not current_user.admin: 620flask.abort(403) 621 622return flask.render_template("put-annotations-form.html", resource=resource) 623 624 625@app.route("/picture/<int:id>/put-annotations-form", methods=["POST"]) 626def put_annotations_form_post(id): 627resource = db.session.get(PictureResource, id) 628if resource is None: 629flask.abort(404) 630 631current_user = db.session.get(User, flask.session.get("username")) 632if current_user is None: 633flask.abort(401) 634 635if resource.author != current_user and not current_user.admin: 636flask.abort(403) 637 638resource.put_annotations(json.loads(flask.request.form["annotations"])) 639 640db.session.commit() 641 642return flask.redirect("/picture/" + str(resource.id)) 643 644 645@app.route("/picture/<int:id>/save-annotations", methods=["POST"]) 646@app.route("/api/picture/<int:id>/put-annotations", methods=["POST"]) 647def save_annotations(id): 648resource = db.session.get(PictureResource, id) 649if resource is None: 650flask.abort(404) 651 652current_user = db.session.get(User, flask.session.get("username")) 653if resource.author != current_user and not current_user.admin: 654flask.abort(403) 655 656resource.put_annotations(flask.request.json) 657 658db.session.commit() 659 660response = flask.make_response() 661response.status_code = 204 662return response 663 664 665@app.route("/picture/<int:id>/get-annotations") 666@app.route("/api/picture/<int:id>/api/get-annotations") 667def get_annotations(id): 668resource = db.session.get(PictureResource, id) 669if resource is None: 670flask.abort(404) 671 672regions = db.session.query(PictureRegion).filter_by(resource_id=id).all() 673 674regions_json = [] 675 676for region in regions: 677regions_json.append({ 678"object": region.object_id, 679"type": region.json["type"], 680"shape": region.json["shape"], 681}) 682 683return flask.jsonify(regions_json) 684 685 686@app.route("/picture/<int:id>/delete") 687def delete_picture(id): 688resource = db.session.get(PictureResource, id) 689if resource is None: 690flask.abort(404) 691 692current_user = db.session.get(User, flask.session.get("username")) 693if current_user is None: 694flask.abort(401) 695 696if resource.author != current_user and not current_user.admin: 697flask.abort(403) 698 699PictureLicence.query.filter_by(resource=resource).delete() 700PictureRegion.query.filter_by(resource=resource).delete() 701PictureInGallery.query.filter_by(resource=resource).delete() 702PictureRating.query.filter_by(resource=resource).delete() 703if resource.replaces: 704resource.replaces.replaced_by = None 705if resource.replaced_by: 706resource.replaced_by.replaces = None 707resource.copied_from = None 708for copy in resource.copies: 709copy.copied_from = None 710db.session.delete(resource) 711db.session.commit() 712 713# Delete the hard links for the picture and its thumbnail 714os.unlink(path.join(config.DATA_PATH, "pictures", str(resource.id))) 715os.unlink(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id))) 716 717return flask.redirect("/") 718 719 720@app.route("/picture/<int:id>/mark-replacement", methods=["POST"]) 721def mark_picture_replacement(id): 722resource = db.session.get(PictureResource, id) 723if resource is None: 724flask.abort(404) 725 726current_user = db.session.get(User, flask.session.get("username")) 727if current_user is None: 728flask.abort(401) 729 730if resource.copied_from.author != current_user and not current_user.admin: 731flask.abort(403) 732 733resource.copied_from.replaced_by = resource 734resource.replaces = resource.copied_from 735 736db.session.commit() 737 738return flask.redirect("/picture/" + str(resource.copied_from.id)) 739 740 741@app.route("/picture/<int:id>/remove-replacement", methods=["POST"]) 742def remove_picture_replacement(id): 743resource = db.session.get(PictureResource, id) 744if resource is None: 745flask.abort(404) 746 747current_user = db.session.get(User, flask.session.get("username")) 748if current_user is None: 749flask.abort(401) 750 751if resource.author != current_user and not current_user.admin: 752flask.abort(403) 753 754resource.replaced_by.replaces = None 755resource.replaced_by = None 756 757db.session.commit() 758 759return flask.redirect("/picture/" + str(resource.id)) 760 761 762@app.route("/picture/<int:id>/edit-metadata") 763def edit_picture(id): 764resource = db.session.get(PictureResource, id) 765if resource is None: 766flask.abort(404) 767 768current_user = db.session.get(User, flask.session.get("username")) 769if current_user is None: 770flask.abort(401) 771 772if resource.author != current_user and not current_user.admin: 773flask.abort(403) 774 775licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), 776Licence.title).all() 777 778types = PictureNature.query.all() 779 780return flask.render_template("edit-picture.html", resource=resource, licences=licences, 781types=types, 782PictureLicence=PictureLicence) 783 784 785@app.route("/picture/<int:id>/rate", methods=["POST"]) 786def rate_picture(id): 787resource = db.session.get(PictureResource, id) 788if resource is None: 789flask.abort(404) 790 791current_user = db.session.get(User, flask.session.get("username")) 792if current_user is None: 793flask.abort(401) 794 795rating = int(flask.request.form.get("rating")) 796 797if not rating: 798# Delete the existing rating 799if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 800db.session.delete(PictureRating.query.filter_by(resource=resource, 801user=current_user).first()) 802db.session.commit() 803 804return flask.redirect("/picture/" + str(resource.id)) 805 806if not 1 <= rating <= 5: 807flask.flash("Invalid rating") 808return flask.redirect("/picture/" + str(resource.id)) 809 810if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 811PictureRating.query.filter_by(resource=resource, user=current_user).first().rating = rating 812else: 813# Create a new rating 814db.session.add(PictureRating(resource, current_user, rating)) 815 816db.session.commit() 817 818return flask.redirect("/picture/" + str(resource.id)) 819 820 821@app.route("/picture/<int:id>/edit-metadata", methods=["POST"]) 822def edit_picture_post(id): 823resource = db.session.get(PictureResource, id) 824if resource is None: 825flask.abort(404) 826 827current_user = db.session.get(User, flask.session.get("username")) 828if current_user is None: 829flask.abort(401) 830 831if resource.author != current_user and not current_user.admin: 832flask.abort(403) 833 834title = flask.request.form["title"] 835description = flask.request.form["description"] 836origin_url = flask.request.form["origin_url"] 837licence_ids = flask.request.form.getlist("licence") 838nature_id = flask.request.form["nature"] 839 840if not title: 841flask.flash("Enter a title") 842return flask.redirect(flask.request.url) 843 844if not description: 845description = "" 846 847if not nature_id: 848flask.flash("Select a picture type") 849return flask.redirect(flask.request.url) 850 851if not licence_ids: 852flask.flash("Select licences") 853return flask.redirect(flask.request.url) 854 855licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 856if not any(licence.free for licence in licences): 857flask.flash("Select at least one free licence") 858return flask.redirect(flask.request.url) 859 860resource.title = title 861resource.description = description 862resource.origin_url = origin_url 863for licence_id in licence_ids: 864joiner = PictureLicence(resource, db.session.get(Licence, licence_id)) 865db.session.add(joiner) 866resource.nature = db.session.get(PictureNature, nature_id) 867 868db.session.commit() 869 870return flask.redirect("/picture/" + str(resource.id)) 871 872 873@app.route("/picture/<int:id>/copy") 874def copy_picture(id): 875resource = db.session.get(PictureResource, id) 876if resource is None: 877flask.abort(404) 878 879current_user = db.session.get(User, flask.session.get("username")) 880if current_user is None: 881flask.abort(401) 882 883new_resource = PictureResource(resource.title, current_user, resource.description, 884resource.origin_url, 885[licence.licence_id for licence in resource.licences], 886resource.file_format, 887resource.nature) 888 889for region in resource.regions: 890db.session.add(PictureRegion(region.json, new_resource, region.object)) 891 892db.session.commit() 893 894# Create a hard link for the new picture 895old_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 896new_path = path.join(config.DATA_PATH, "pictures", str(new_resource.id)) 897os.link(old_path, new_path) 898 899new_resource.width = resource.width 900new_resource.height = resource.height 901new_resource.copied_from = resource 902 903db.session.commit() 904 905return flask.redirect("/picture/" + str(new_resource.id)) 906 907 908@app.route("/picture/<int:id>/put-file") 909def put_file(id): 910resource = db.session.get(PictureResource, id) 911if resource is None: 912flask.abort(404) 913 914current_user = db.session.get(User, flask.session.get("username")) 915if current_user is None: 916flask.abort(401) 917 918if resource.author != current_user and not current_user.admin: 919flask.abort(403) 920 921return flask.render_template("put-file.html", resource=resource) 922 923 924@app.route("/picture/<int:id>/put-file", methods=["POST"]) 925def put_file_post(id): 926resource = db.session.get(PictureResource, id) 927if resource is None: 928flask.abort(404) 929 930current_user = db.session.get(User, flask.session.get("username")) 931if current_user is None: 932flask.abort(401) 933 934if resource.author != current_user and not current_user.admin: 935flask.abort(403) 936 937file = flask.request.files["file"] 938 939if not file or not file.filename: 940flask.flash("Select a file") 941return flask.redirect(flask.request.url) 942 943if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 944flask.flash("Only images are supported") 945return flask.redirect(flask.request.url) 946 947file_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 948# Since it's a hard link, we need to remove it, so it won't affect copies 949os.unlink(file_path) 950file.save(file_path) 951pil_image = Image.open(file_path) 952resource.width, resource.height = pil_image.size 953pil_image = make_thumbnail(pil_image) 954pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS) 955 956return flask.redirect("/picture/" + str(resource.id)) 957 958 959@app.route("/gallery/<int:id>/") 960def gallery(id): 961gallery = db.session.get(Gallery, id) 962if gallery is None: 963flask.abort(404) 964 965current_user = db.session.get(User, flask.session.get("username")) 966 967have_permission = current_user and (current_user == gallery.owner or current_user.admin or UserInGallery.query.filter_by(user=current_user, gallery=gallery).first()) 968have_extended_permission = current_user and (current_user == gallery.owner or current_user.admin) 969 970return flask.render_template("gallery.html", gallery=gallery, 971have_permission=have_permission, 972have_extended_permission=have_extended_permission) 973 974 975@app.route("/create-gallery") 976def create_gallery(): 977if "username" not in flask.session: 978flask.flash("Log in to create galleries.") 979return flask.redirect("/accounts") 980 981return flask.render_template("create-gallery.html") 982 983 984@app.route("/create-gallery", methods=["POST"]) 985def create_gallery_post(): 986if not flask.session.get("username"): 987flask.abort(401) 988 989if not flask.request.form.get("title"): 990flask.flash("Enter a title") 991return flask.redirect(flask.request.url) 992 993description = flask.request.form.get("description", "") 994 995gallery = Gallery(flask.request.form["title"], description, 996db.session.get(User, flask.session["username"])) 997db.session.add(gallery) 998db.session.commit() 999 1000return flask.redirect("/gallery/" + str(gallery.id)) 1001 1002 1003@app.route("/gallery/<int:id>/add-picture", methods=["POST"]) 1004def gallery_add_picture(id): 1005gallery = db.session.get(Gallery, id) 1006if gallery is None: 1007flask.abort(404) 1008 1009if "username" not in flask.session: 1010flask.abort(401) 1011 1012if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1013flask.abort(403) 1014 1015picture_id = flask.request.form.get("picture_id") 1016if "/" in picture_id: # also allow full URLs 1017picture_id = picture_id.rstrip("/").rpartition("/")[1] 1018if not picture_id: 1019flask.flash("Select a picture") 1020return flask.redirect("/gallery/" + str(gallery.id)) 1021picture_id = int(picture_id) 1022 1023picture = db.session.get(PictureResource, picture_id) 1024if picture is None: 1025flask.flash("Invalid picture") 1026return flask.redirect("/gallery/" + str(gallery.id)) 1027 1028if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 1029flask.flash("This picture is already in the gallery") 1030return flask.redirect("/gallery/" + str(gallery.id)) 1031 1032db.session.add(PictureInGallery(picture, gallery)) 1033 1034db.session.commit() 1035 1036return flask.redirect("/gallery/" + str(gallery.id)) 1037 1038 1039@app.route("/gallery/<int:id>/remove-picture", methods=["POST"]) 1040def gallery_remove_picture(id): 1041gallery = db.session.get(Gallery, id) 1042if gallery is None: 1043flask.abort(404) 1044 1045if "username" not in flask.session: 1046flask.abort(401) 1047 1048current_user = db.session.get(User, flask.session.get("username")) 1049 1050if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1051flask.abort(403) 1052 1053picture_id = int(flask.request.form.get("picture_id")) 1054 1055picture = db.session.get(PictureResource, picture_id) 1056if picture is None: 1057flask.flash("Invalid picture") 1058return flask.redirect("/gallery/" + str(gallery.id)) 1059 1060picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, 1061gallery=gallery).first() 1062if picture_in_gallery is None: 1063flask.flash("This picture isn't in the gallery") 1064return flask.redirect("/gallery/" + str(gallery.id)) 1065 1066db.session.delete(picture_in_gallery) 1067 1068db.session.commit() 1069 1070return flask.redirect("/gallery/" + str(gallery.id)) 1071 1072 1073@app.route("/gallery/<int:id>/add-pictures-from-query", methods=["POST"]) 1074def gallery_add_from_query(id): 1075gallery = db.session.get(Gallery, id) 1076if gallery is None: 1077flask.abort(404) 1078 1079if "username" not in flask.session: 1080flask.abort(401) 1081 1082if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1083flask.abort(403) 1084 1085query_yaml = flask.request.form.get("query", "") 1086 1087yaml_parser = yaml.YAML() 1088query_data = yaml_parser.load(query_yaml) or {} 1089query = get_picture_query(query_data) 1090 1091pictures = query.all() 1092 1093count = 0 1094 1095for picture in pictures: 1096if not PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 1097db.session.add(PictureInGallery(picture, gallery)) 1098count += 1 1099 1100db.session.commit() 1101 1102flask.flash(f"Added {count} pictures to the gallery") 1103 1104return flask.redirect("/gallery/" + str(gallery.id)) 1105 1106 1107@app.route("/gallery/<int:id>/users") 1108def gallery_users(id): 1109gallery = db.session.get(Gallery, id) 1110if gallery is None: 1111flask.abort(404) 1112 1113current_user = db.session.get(User, flask.session.get("username")) 1114have_permission = current_user and (current_user == gallery.owner or current_user.admin) 1115 1116return flask.render_template("gallery-users.html", gallery=gallery, 1117have_permission=have_permission) 1118 1119 1120@app.route("/gallery/<int:id>/edit") 1121def edit_gallery(id): 1122gallery = db.session.get(Gallery, id) 1123if gallery is None: 1124flask.abort(404) 1125 1126current_user = db.session.get(User, flask.session.get("username")) 1127if current_user is None: 1128flask.abort(401) 1129 1130if current_user != gallery.owner and not current_user.admin: 1131flask.abort(403) 1132 1133return flask.render_template("edit-gallery.html", gallery=gallery) 1134 1135 1136@app.route("/gallery/<int:id>/edit", methods=["POST"]) 1137def edit_gallery_post(id): 1138gallery = db.session.get(Gallery, id) 1139if gallery is None: 1140flask.abort(404) 1141 1142current_user = db.session.get(User, flask.session.get("username")) 1143if current_user is None: 1144flask.abort(401) 1145 1146if current_user != gallery.owner and not current_user.admin: 1147flask.abort(403) 1148 1149title = flask.request.form["title"] 1150description = flask.request.form.get("description") 1151 1152if not title: 1153flask.flash("Enter a title") 1154return flask.redirect(flask.request.url) 1155 1156if not description: 1157description = "" 1158 1159gallery.title = title 1160gallery.description = description 1161 1162db.session.commit() 1163 1164return flask.redirect("/gallery/" + str(gallery.id)) 1165 1166 1167@app.route("/gallery/<int:id>/delete") 1168def delete_gallery(id): 1169gallery = db.session.get(Gallery, id) 1170if gallery is None: 1171flask.abort(404) 1172 1173current_user = db.session.get(User, flask.session.get("username")) 1174if current_user is None: 1175flask.abort(401) 1176 1177if current_user != gallery.owner and not current_user.admin: 1178flask.abort(403) 1179 1180PictureInGallery.query.filter_by(gallery=gallery).delete() 1181UserInGallery.query.filter_by(gallery=gallery).delete() 1182db.session.delete(gallery) 1183db.session.commit() 1184 1185return flask.redirect("/") 1186 1187 1188@app.route("/gallery/<int:id>/users/add", methods=["POST"]) 1189def gallery_add_user(id): 1190gallery = db.session.get(Gallery, id) 1191if gallery is None: 1192flask.abort(404) 1193 1194current_user = db.session.get(User, flask.session.get("username")) 1195if current_user is None: 1196flask.abort(401) 1197 1198if current_user != gallery.owner and not current_user.admin: 1199flask.abort(403) 1200 1201username = flask.request.form.get("username") 1202if username == gallery.owner_name: 1203flask.flash("The owner is already in the gallery") 1204return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1205 1206user = db.session.get(User, username) 1207if user is None: 1208flask.flash("User not found") 1209return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1210 1211if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 1212flask.flash("User is already in the gallery") 1213return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1214 1215db.session.add(UserInGallery(user, gallery)) 1216 1217db.session.commit() 1218 1219return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1220 1221 1222@app.route("/gallery/<int:id>/users/remove", methods=["POST"]) 1223def gallery_remove_user(id): 1224gallery = db.session.get(Gallery, id) 1225if gallery is None: 1226flask.abort(404) 1227 1228current_user = db.session.get(User, flask.session.get("username")) 1229if current_user is None: 1230flask.abort(401) 1231 1232if current_user != gallery.owner and not current_user.admin: 1233flask.abort(403) 1234 1235username = flask.request.form.get("username") 1236user = db.session.get(User, username) 1237if user is None: 1238flask.flash("User not found") 1239return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1240 1241user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 1242if user_in_gallery is None: 1243flask.flash("User is not in the gallery") 1244return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1245 1246db.session.delete(user_in_gallery) 1247 1248db.session.commit() 1249 1250return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1251 1252 1253class APIError(Exception): 1254def __init__(self, status_code, message): 1255self.status_code = status_code 1256self.message = message 1257 1258 1259def get_picture_query(query_data): 1260query = db.session.query(PictureResource) 1261 1262def has_condition(id): 1263descendants_cte = ( 1264db.select(PictureObject.id) 1265.where(PictureObject.id == id) 1266.cte(name=f"descendants_cte_{id}", recursive=True) 1267) 1268 1269descendants_cte = descendants_cte.union_all( 1270db.select(PictureObjectInheritance.child_id) 1271.where(PictureObjectInheritance.parent_id == descendants_cte.c.id) 1272) 1273 1274return PictureResource.regions.any( 1275PictureRegion.object_id.in_( 1276db.select(descendants_cte.c.id) 1277) 1278) 1279 1280requirement_conditions = { 1281# Has an object with the ID in the given list 1282"has_object": lambda value: PictureResource.regions.any( 1283PictureRegion.object_id.in_(value)), 1284# Has an object with the ID in the given list, or a subtype of it 1285"has": lambda value: db.or_(*[has_condition(id) for id in value]), 1286"nature": lambda value: PictureResource.nature_id.in_(value), 1287"licence": lambda value: PictureResource.licences.any( 1288PictureLicence.licence_id.in_(value)), 1289"author": lambda value: PictureResource.author_name.in_(value), 1290"title": lambda value: PictureResource.title.ilike(value), 1291"description": lambda value: PictureResource.description.ilike(value), 1292"origin_url": lambda value: db.func.lower(db.func.substr( 1293PictureResource.origin_url, 1294db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 1295)).in_(value), 1296"above_width": lambda value: PictureResource.width >= value, 1297"below_width": lambda value: PictureResource.width <= value, 1298"above_height": lambda value: PictureResource.height >= value, 1299"below_height": lambda value: PictureResource.height <= value, 1300"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 1301value), 1302"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 1303value), 1304"in_gallery": lambda value: PictureResource.galleries.any(PictureInGallery.gallery_id.in_(value)), 1305"above_rating": lambda value: db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 5)).where(PictureRating.resource_id == PictureResource.id).scalar_subquery() >= value, 1306"below_rating": lambda value: db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 0)).where(PictureRating.resource_id == PictureResource.id).scalar_subquery() <= value, 1307"above_rating_count": lambda value: db.select(db.func.count(PictureRating.id)).where(PictureRating.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() >= value, 1308"below_rating_count": lambda value: db.select(db.func.count(PictureRating.id)).where(PictureRating.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() <= value, 1309"above_region_count": lambda value: db.select(db.func.count(PictureRegion.id)).where(PictureRegion.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() >= value, 1310"below_region_count": lambda value: db.select(db.func.count(PictureRegion.id)).where(PictureRegion.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() <= value, 1311"copied_from": lambda value: PictureResource.copied_from_id.in_(value), 1312} 1313 1314if "want" in query_data: 1315for i in query_data["want"]: 1316if len(i) != 1: 1317raise APIError(400, "Each requirement must have exactly one key") 1318requirement, value = list(i.items())[0] 1319if requirement not in requirement_conditions: 1320raise APIError(400, f"Unknown requirement type: {requirement}") 1321 1322condition = requirement_conditions[requirement] 1323query = query.filter(condition(value)) 1324if "exclude" in query_data: 1325for i in query_data["exclude"]: 1326if len(i) != 1: 1327raise APIError(400, "Each exclusion must have exactly one key") 1328requirement, value = list(i.items())[0] 1329if requirement not in requirement_conditions: 1330raise APIError(400, f"Unknown requirement type: {requirement}") 1331 1332condition = requirement_conditions[requirement] 1333query = query.filter(~condition(value)) 1334if not query_data.get("include_obsolete", False): 1335query = query.filter(PictureResource.replaced_by_id.is_(None)) 1336 1337return query 1338 1339 1340@app.route("/query-pictures") 1341def graphical_query_pictures(): 1342return flask.render_template("graphical-query-pictures.html") 1343 1344 1345@app.route("/query-pictures-results") 1346def graphical_query_pictures_results(): 1347query_yaml = flask.request.args.get("query", "") 1348yaml_parser = yaml.YAML() 1349query_data = yaml_parser.load(query_yaml) or {} 1350try: 1351query = get_picture_query(query_data) 1352except APIError as e: 1353flask.abort(e.status_code) 1354 1355page = int(flask.request.args.get("page", 1)) 1356per_page = int(flask.request.args.get("per_page", 16)) 1357 1358resources = query.paginate(page=page, per_page=per_page) 1359 1360return flask.render_template("graphical-query-pictures-results.html", resources=resources, 1361query=query_yaml, 1362page_number=page, page_length=per_page, 1363num_pages=resources.pages, 1364prev_page=resources.prev_num, next_page=resources.next_num) 1365 1366 1367@app.route("/raw/picture/<int:id>") 1368def raw_picture(id): 1369resource = db.session.get(PictureResource, id) 1370if resource is None: 1371flask.abort(404) 1372 1373response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), 1374str(resource.id)) 1375response.mimetype = resource.file_format 1376 1377return response 1378 1379 1380@app.route("/raw/picture-thumbnail/<int:id>") 1381def raw_picture_thumbnail(id): 1382resource = db.session.get(PictureResource, id) 1383if resource is None: 1384flask.abort(404) 1385 1386if not path.exists(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id))): 1387pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1388pil_image = make_thumbnail(pil_image) 1389pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS) 1390 1391response = flask.send_from_directory(path.join(config.DATA_PATH, "picture-thumbnails"), 1392str(resource.id)) 1393response.mimetype = resource.file_format 1394 1395return response 1396 1397 1398@app.route("/object/") 1399def graphical_object_types(): 1400return flask.render_template("object-types.html", objects=PictureObject.query.all()) 1401 1402 1403@app.route("/api/object-types") 1404def object_types(): 1405objects = db.session.query(PictureObject).all() 1406return flask.jsonify({object.id: object.description for object in objects}) 1407 1408 1409@app.route("/api/query-pictures", methods=["POST"]) # sadly GET can't have a body 1410def query_pictures(): 1411offset = int(flask.request.args.get("offset", 0)) 1412limit = int(flask.request.args.get("limit", 16)) 1413ordering = flask.request.args.get("ordering", "date-desc") 1414 1415yaml_parser = yaml.YAML() 1416query_data = yaml_parser.load(flask.request.data) or {} 1417try: 1418query = get_picture_query(query_data) 1419except APIError as e: 1420return flask.jsonify({"error": e.message}), e.status_code 1421 1422rating_count_subquery = db.select(db.func.count(PictureRating.id)).where( 1423PictureRating.resource_id == PictureResource.id).scalar_subquery() 1424region_count_subquery = db.select(db.func.count(PictureRegion.id)).where( 1425PictureRegion.resource_id == PictureResource.id).scalar_subquery() 1426rating_subquery = db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 0)).where( 1427PictureRating.resource_id == PictureResource.id).scalar_subquery() 1428 1429match ordering: 1430case "date-desc": 1431query = query.order_by(PictureResource.timestamp.desc()) 1432case "date-asc": 1433query = query.order_by(PictureResource.timestamp.asc()) 1434case "title-asc": 1435query = query.order_by(PictureResource.title.asc()) 1436case "title-desc": 1437query = query.order_by(PictureResource.title.desc()) 1438case "random": 1439query = query.order_by(db.func.random()) 1440case "number-regions-desc": 1441query = query.order_by(region_count_subquery.desc()) 1442case "number-regions-asc": 1443query = query.order_by(region_count_subquery.asc()) 1444case "rating-desc": 1445query = query.order_by(rating_subquery.desc()) 1446case "rating-asc": 1447query = query.order_by(rating_subquery.asc()) 1448case "number-ratings-desc": 1449query = query.order_by(rating_count_subquery.desc()) 1450case "number-ratings-asc": 1451query = query.order_by(rating_count_subquery.asc()) 1452 1453query = query.offset(offset).limit(limit) 1454resources = query.all() 1455 1456json_response = { 1457"date_generated": datetime.utcnow().timestamp(), 1458"resources": [], 1459"offset": offset, 1460"limit": limit, 1461} 1462 1463json_resources = json_response["resources"] 1464 1465for resource in resources: 1466json_resource = { 1467"id": resource.id, 1468"title": resource.title, 1469"description": resource.description, 1470"timestamp": resource.timestamp.timestamp(), 1471"origin_url": resource.origin_url, 1472"author": resource.author_name, 1473"file_format": resource.file_format, 1474"width": resource.width, 1475"height": resource.height, 1476"nature": resource.nature_id, 1477"licences": [licence.licence_id for licence in resource.licences], 1478"replaces": resource.replaces_id, 1479"replaced_by": resource.replaced_by_id, 1480"regions": [], 1481"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1482} 1483for region in resource.regions: 1484json_resource["regions"].append({ 1485"object": region.object_id, 1486"type": region.json["type"], 1487"shape": region.json["shape"], 1488}) 1489 1490json_resources.append(json_resource) 1491 1492return flask.jsonify(json_response) 1493 1494 1495@app.route("/api/picture/<int:id>/") 1496def api_picture(id): 1497resource = db.session.get(PictureResource, id) 1498if resource is None: 1499flask.abort(404) 1500 1501json_resource = { 1502"id": resource.id, 1503"title": resource.title, 1504"description": resource.description, 1505"timestamp": resource.timestamp.timestamp(), 1506"origin_url": resource.origin_url, 1507"author": resource.author_name, 1508"file_format": resource.file_format, 1509"width": resource.width, 1510"height": resource.height, 1511"nature": resource.nature_id, 1512"licences": [licence.licence_id for licence in resource.licences], 1513"replaces": resource.replaces_id, 1514"replaced_by": resource.replaced_by_id, 1515"regions": [], 1516"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1517"rating_average": resource.average_rating, 1518"rating_count": resource.rating_totals, 1519} 1520for region in resource.regions: 1521json_resource["regions"].append({ 1522"object": region.object_id, 1523"type": region.json["type"], 1524"shape": region.json["shape"], 1525}) 1526 1527return flask.jsonify(json_resource) 1528 1529 1530@app.route("/api/licence/") 1531def api_licences(): 1532licences = db.session.query(Licence).all() 1533json_licences = { 1534licence.id: { 1535"title": licence.title, 1536"free": licence.free, 1537"pinned": licence.pinned, 1538} for licence in licences 1539} 1540 1541return flask.jsonify(json_licences) 1542 1543 1544@app.route("/api/licence/<id>/") 1545def api_licence(id): 1546licence = db.session.get(Licence, id) 1547if licence is None: 1548flask.abort(404) 1549 1550json_licence = { 1551"id": licence.id, 1552"title": licence.title, 1553"description": licence.description, 1554"info_url": licence.info_url, 1555"legalese_url": licence.url, 1556"free": licence.free, 1557"logo_url": licence.logo_url, 1558"pinned": licence.pinned, 1559} 1560 1561return flask.jsonify(json_licence) 1562 1563 1564@app.route("/api/nature/") 1565def api_natures(): 1566natures = db.session.query(PictureNature).all() 1567json_natures = { 1568nature.id: nature.description for nature in natures 1569} 1570 1571return flask.jsonify(json_natures) 1572 1573 1574@app.route("/api/user/") 1575def api_users(): 1576offset = int(flask.request.args.get("offset", 0)) 1577limit = int(flask.request.args.get("limit", 16)) 1578 1579users = db.session.query(User).offset(offset).limit(limit).all() 1580 1581json_users = { 1582user.username: { 1583"admin": user.admin, 1584} for user in users 1585} 1586 1587return flask.jsonify(json_users) 1588 1589 1590@app.route("/api/user/<username>/") 1591def api_user(username): 1592user = db.session.get(User, username) 1593if user is None: 1594flask.abort(404) 1595 1596json_user = { 1597"username": user.username, 1598"admin": user.admin, 1599"joined": user.joined_timestamp.timestamp(), 1600} 1601 1602return flask.jsonify(json_user) 1603 1604 1605@app.route("/api/login", methods=["POST"]) 1606def api_login(): 1607username = flask.request.json["username"] 1608password = flask.request.json["password"] 1609 1610user = db.session.get(User, username) 1611 1612if user is None: 1613return flask.jsonify({"error": "This username is not registered. To prevent spam, you must use the HTML interface to register."}), 401 1614 1615if not bcrypt.check_password_hash(user.password_hashed, password): 1616return flask.jsonify({"error": "Incorrect password"}), 401 1617 1618flask.session["username"] = username 1619 1620return flask.jsonify({"message": "You have been logged in. Your HTTP client must support cookies to use features of this API that require authentication."}) 1621 1622 1623@app.route("/api/logout", methods=["POST"]) 1624def api_logout(): 1625flask.session.pop("username", None) 1626return flask.jsonify({"message": "You have been logged out."}) 1627 1628 1629@app.route("/api/upload", methods=["POST"]) 1630def api_upload(): 1631if "username" not in flask.session: 1632return flask.jsonify({"error": "You must be logged in to upload pictures"}), 401 1633 1634json_ = json.loads(flask.request.form["json"]) 1635title = json_["title"] 1636description = json_.get("description", "") 1637origin_url = json_.get("origin_url", "") 1638author = db.session.get(User, flask.session["username"]) 1639licence_ids = json_["licence"] 1640nature_id = json_["nature"] 1641file = flask.request.files["file"] 1642 1643if not file or not file.filename: 1644return flask.jsonify({"error": "An image file must be uploaded"}), 400 1645 1646if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 1647return flask.jsonify({"error": "Only bitmap images are supported"}), 400 1648 1649if not title: 1650return flask.jsonify({"error": "Give a title"}), 400 1651 1652if not description: 1653description = "" 1654 1655if not nature_id: 1656return flask.jsonify({"error": "Give a picture type"}), 400 1657 1658if not licence_ids: 1659return flask.jsonify({"error": "Give licences"}), 400 1660 1661licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1662if not any(licence.free for licence in licences): 1663return flask.jsonify({"error": "Use at least one free licence"}), 400 1664 1665resource = PictureResource(title, author, description, origin_url, licence_ids, 1666file.mimetype, 1667db.session.get(PictureNature, nature_id)) 1668db.session.add(resource) 1669db.session.commit() 1670file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1671pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1672resource.width, resource.height = pil_image.size 1673pil_image.thumbnail(config.THUMBNAIL_SIZE) 1674pil_image = pil_image.convert("RGB") 1675pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS) 1676db.session.commit() 1677 1678if json_.get("annotations"): 1679try: 1680resource.put_annotations(json_["annotations"]) 1681db.session.commit() 1682except json.JSONDecodeError: 1683return flask.jsonify({"error": "Invalid annotations"}), 400 1684 1685return flask.jsonify({"message": "Picture uploaded successfully", "id": resource.id}) 1686 1687 1688@app.route("/api/picture/<int:id>/update", methods=["POST"]) 1689def api_update_picture(id): 1690resource = db.session.get(PictureResource, id) 1691if resource is None: 1692return flask.jsonify({"error": "Picture not found"}), 404 1693current_user = db.session.get(User, flask.session.get("username")) 1694if current_user is None: 1695return flask.jsonify({"error": "You must be logged in to edit pictures"}), 401 1696if resource.author != current_user and not current_user.admin: 1697return flask.jsonify({"error": "You are not the author of this picture"}), 403 1698 1699title = flask.request.json.get("title", resource.title) 1700description = flask.request.json.get("description", resource.description) 1701origin_url = flask.request.json.get("origin_url", resource.origin_url) 1702licence_ids = flask.request.json.get("licence", [licence.licence_id for licence in resource.licences]) 1703nature_id = flask.request.json.get("nature", resource.nature_id) 1704 1705if not title: 1706return flask.jsonify({"error": "Give a title"}), 400 1707 1708if not description: 1709description = "" 1710 1711if not nature_id: 1712return flask.jsonify({"error": "Give a picture type"}), 400 1713 1714if not licence_ids: 1715return flask.jsonify({"error": "Give licences"}), 400 1716 1717licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1718 1719if not any(licence.free for licence in licences): 1720return flask.jsonify({"error": "Use at least one free licence"}), 400 1721 1722resource.title = title 1723resource.description = description 1724resource.origin_url = origin_url 1725resource.licences = licences 1726resource.nature = db.session.get(PictureNature, nature_id) 1727 1728db.session.commit() 1729 1730return flask.jsonify({"message": "Picture updated successfully"}) 1731 1732 1733@app.route("/api/picture/<int:id>/delete", methods=["POST"]) 1734def api_delete_picture(id): 1735resource = db.session.get(PictureResource, id) 1736if resource is None: 1737return flask.jsonify({"error": "Picture not found"}), 404 1738current_user = db.session.get(User, flask.session.get("username")) 1739if current_user is None: 1740return flask.jsonify({"error": "You must be logged in to delete pictures"}), 401 1741if resource.author != current_user and not current_user.admin: 1742return flask.jsonify({"error": "You are not the author of this picture"}), 403 1743 1744PictureInGallery.query.filter_by(resource=resource).delete() 1745db.session.delete(resource) 1746db.session.commit() 1747 1748return flask.jsonify({"message": "Picture deleted successfully"}) 1749 1750 1751@app.route("/api/picture/<int:id>/put-file", methods=["POST"]) 1752def api_put_file(id): 1753resource = db.session.get(PictureResource, id) 1754if resource is None: 1755return flask.jsonify({"error": "Picture not found"}), 404 1756current_user = db.session.get(User, flask.session.get("username")) 1757if current_user is None: 1758return flask.jsonify({"error": "You must be logged in to edit pictures"}), 401 1759if resource.author != current_user and not current_user.admin: 1760return flask.jsonify({"error": "You are not the author of this picture"}), 403 1761 1762file = flask.request.files["file"] 1763 1764if not file or not file.filename: 1765return flask.jsonify({"error": "Provide a file"}), 400 1766 1767if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 1768return flask.jsonify({"error": "Only images are supported"}), 415 1769 1770file_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 1771# Since it's a hard link, we need to remove it, so it won't affect copies 1772os.unlink(file_path) 1773file.save(file_path) 1774pil_image = Image.open(file_path) 1775resource.width, resource.height = pil_image.size 1776pil_image = make_thumbnail(pil_image) 1777pil_image.save(path.join(config.DATA_PATH, "picture-thumbnails", str(resource.id)), **config.THUMBNAIL_SAVE_OPTIONS) 1778 1779return flask.jsonify({"message": "File uploaded successfully"}) 1780 1781 1782@app.route("/api/picture/<int:id>/rate", methods=["POST"]) 1783def api_rate_picture(id): 1784resource = db.session.get(PictureResource, id) 1785if resource is None: 1786flask.abort(404) 1787 1788current_user = db.session.get(User, flask.session.get("username")) 1789if current_user is None: 1790flask.abort(401) 1791 1792rating = int(flask.request.json.get("rating", 0)) 1793 1794if not rating: 1795# Delete the existing rating 1796if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 1797db.session.delete(PictureRating.query.filter_by(resource=resource, 1798user=current_user).first()) 1799db.session.commit() 1800 1801return flask.jsonify({"message": "Existing rating removed"}) 1802 1803if not 1 <= rating <= 5: 1804flask.flash("Invalid rating") 1805return flask.jsonify({"error": "Invalid rating"}), 400 1806 1807if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 1808PictureRating.query.filter_by(resource=resource, user=current_user).first().rating = rating 1809else: 1810# Create a new rating 1811db.session.add(PictureRating(resource, current_user, rating)) 1812 1813db.session.commit() 1814 1815return flask.jsonify({"message": "Rating saved"}) 1816 1817 1818@app.route("/api/gallery/<int:id>/") 1819def api_gallery(id): 1820gallery = db.session.get(Gallery, id) 1821if gallery is None: 1822flask.abort(404) 1823 1824json_gallery = { 1825"id": gallery.id, 1826"title": gallery.title, 1827"description": gallery.description, 1828"owner": gallery.owner_name, 1829"users": [user.username for user in gallery.users], 1830} 1831 1832return flask.jsonify(json_gallery) 1833 1834 1835@app.route("/api/gallery/<int:id>/edit", methods=["POST"]) 1836def api_edit_gallery(id): 1837gallery = db.session.get(Gallery, id) 1838if gallery is None: 1839flask.abort(404) 1840 1841current_user = db.session.get(User, flask.session.get("username")) 1842if current_user is None: 1843flask.abort(401) 1844 1845if current_user != gallery.owner and not current_user.admin: 1846flask.abort(403) 1847 1848title = flask.request.json.get("title", gallery.title) 1849description = flask.request.json.get("description", gallery.description) 1850 1851if not title: 1852return flask.jsonify({"error": "Give a title"}), 400 1853 1854if not description: 1855description = "" 1856 1857gallery.title = title 1858gallery.description = description 1859 1860db.session.commit() 1861 1862return flask.jsonify({"message": "Gallery updated successfully"}) 1863 1864 1865@app.route("/api/new-gallery", methods=["POST"]) 1866def api_new_gallery(): 1867if "username" not in flask.session: 1868return flask.jsonify({"error": "You must be logged in to create galleries"}), 401 1869 1870title = flask.request.json.get("title") 1871description = flask.request.json.get("description", "") 1872 1873if not title: 1874return flask.jsonify({"error": "Give a title"}), 400 1875 1876gallery = Gallery(title, description, db.session.get(User, flask.session["username"])) 1877db.session.add(gallery) 1878db.session.commit() 1879 1880return flask.jsonify({"message": "Gallery created successfully", "id": gallery.id}) 1881 1882 1883@app.route("/api/gallery/<int:id>/add-picture", methods=["POST"]) 1884def api_gallery_add_picture(id): 1885gallery = db.session.get(Gallery, id) 1886if gallery is None: 1887flask.abort(404) 1888 1889if "username" not in flask.session: 1890return flask.jsonify({"error": "You must be logged in to add pictures to galleries"}), 401 1891 1892current_user = db.session.get(User, flask.session.get("username")) 1893 1894if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1895return flask.jsonify({"error": "You do not have permission to add pictures to this gallery"}), 403 1896 1897picture_id = flask.request.json.get("picture_id") 1898 1899try: 1900picture_id = int(picture_id) 1901except ValueError: 1902return flask.jsonify({"error": "Invalid picture ID"}), 400 1903 1904picture = db.session.get(PictureResource, picture_id) 1905if picture is None: 1906return flask.jsonify({"error": "The picture doesn't exist"}), 404 1907 1908if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 1909return flask.jsonify({"error": "This picture is already in the gallery"}), 400 1910 1911db.session.add(PictureInGallery(picture, gallery)) 1912 1913db.session.commit() 1914 1915return flask.jsonify({"message": "Picture added to gallery"}) 1916 1917 1918@app.route("/api/gallery/<int:id>/remove-picture", methods=["POST"]) 1919def api_gallery_remove_picture(id): 1920gallery = db.session.get(Gallery, id) 1921if gallery is None: 1922flask.abort(404) 1923 1924if "username" not in flask.session: 1925return flask.jsonify({"error": "You must be logged in to remove pictures from galleries"}), 401 1926 1927current_user = db.session.get(User, flask.session.get("username")) 1928 1929if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1930return flask.jsonify({"error": "You do not have permission to remove pictures from this gallery"}), 403 1931 1932picture_id = flask.request.json.get("picture_id") 1933 1934try: 1935picture_id = int(picture_id) 1936except ValueError: 1937return flask.jsonify({"error": "Invalid picture ID"}), 400 1938 1939picture = db.session.get(PictureResource, picture_id) 1940if picture is None: 1941return flask.jsonify({"error": "The picture doesn't exist"}), 404 1942 1943picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first() 1944if picture_in_gallery is None: 1945return flask.jsonify({"error": "This picture isn't in the gallery"}), 400 1946 1947db.session.delete(picture_in_gallery) 1948 1949db.session.commit() 1950 1951return flask.jsonify({"message": "Picture removed from gallery"}) 1952 1953 1954@app.route("/api/gallery/<int:id>/users/add", methods=["POST"]) 1955def api_gallery_add_user(id): 1956gallery = db.session.get(Gallery, id) 1957if gallery is None: 1958flask.abort(404) 1959 1960current_user = db.session.get(User, flask.session.get("username")) 1961if current_user is None: 1962flask.abort(401) 1963 1964if current_user != gallery.owner and not current_user.admin: 1965flask.abort(403) 1966 1967username = flask.request.json.get("username") 1968if username == gallery.owner_name: 1969return flask.jsonify({"error": "The owner cannot be added to trusted users"}), 400 1970 1971user = db.session.get(User, username) 1972if user is None: 1973return flask.jsonify({"error": "User not found"}), 404 1974 1975if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 1976return flask.jsonify({"error": "User is already in the gallery"}), 400 1977 1978db.session.add(UserInGallery(user, gallery)) 1979 1980db.session.commit() 1981 1982return flask.jsonify({"message": "User added to gallery"}) 1983 1984 1985@app.route("/api/gallery/<int:id>/users/remove", methods=["POST"]) 1986def api_gallery_remove_user(id): 1987gallery = db.session.get(Gallery, id) 1988if gallery is None: 1989flask.abort(404) 1990 1991current_user = db.session.get(User, flask.session.get("username")) 1992if current_user is None: 1993flask.abort(401) 1994 1995if current_user != gallery.owner and not current_user.admin: 1996flask.abort(403) 1997 1998username = flask.request.json.get("username") 1999user = db.session.get(User, username) 2000if user is None: 2001return flask.jsonify({"error": "User not found"}), 404 2002 2003user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 2004if user_in_gallery is None: 2005return flask.jsonify({"error": "User is not in the gallery"}), 400 2006 2007db.session.delete(user_in_gallery) 2008 2009db.session.commit() 2010 2011return flask.jsonify({"message": "User removed from gallery"}) 2012 2013 2014@app.route("/api/gallery/<int:id>/delete", methods=["POST"]) 2015def api_delete_gallery(id): 2016gallery = db.session.get(Gallery, id) 2017if gallery is None: 2018flask.abort(404) 2019 2020current_user = db.session.get(User, flask.session.get("username")) 2021if current_user is None: 2022flask.abort(401) 2023 2024if current_user != gallery.owner and not current_user.admin: 2025flask.abort(403) 2026 2027PictureInGallery.query.filter_by(gallery=gallery).delete() 2028UserInGallery.query.filter_by(gallery=gallery).delete() 2029db.session.delete(gallery) 2030 2031db.session.commit() 2032 2033return flask.jsonify({"message": "Gallery deleted"}) 2034 2035 2036def random_picture(): 2037return db.session.query(PictureResource).order_by(db.func.random()).first() 2038 2039 2040for code in range(400, 600): 2041if os.path.exists(f"templates/errors/{code}.html"): 2042app.register_error_handler(code, lambda e: error_page(e.code)) 2043 2044 2045@app.route("/error/<int:code>") 2046def error_page(code): 2047user_agent = flask.request.headers.get("User-Agent", "") 2048ip_address = flask.request.remote_addr 2049return flask.render_template(f"errors/{code}.html", random_picture=random_picture(), 2050user_agent=user_agent, ip_address=ip_address), code 2051 2052