app.py
Python script, ASCII text executable
1import json 2from datetime import datetime 3from email.policy import default 4from time import perf_counter 5 6import flask 7from flask_sqlalchemy import SQLAlchemy 8from flask_bcrypt import Bcrypt 9from flask_httpauth import HTTPBasicAuth 10from markupsafe import escape, Markup 11from flask_migrate import Migrate, current 12from jinja2_fragments.flask import render_block 13from sqlalchemy.orm import backref 14import sqlalchemy.dialects.postgresql 15from os import path 16import os 17from urllib.parse import urlencode 18import mimetypes 19import ruamel.yaml as yaml 20 21from PIL import Image 22 23import config 24import markdown 25 26 27app = flask.Flask(__name__) 28bcrypt = Bcrypt(app) 29 30 31app.config["SQLALCHEMY_DATABASE_URI"] = config.DB_URI 32app.config["SECRET_KEY"] = config.DB_PASSWORD 33 34 35db = SQLAlchemy(app) 36migrate = Migrate(app, db) 37 38 39@app.template_filter("split") 40def split(value, separator=None, maxsplit=-1): 41return value.split(separator, maxsplit) 42 43 44@app.template_filter("median") 45def median(value): 46value = list(value) # prevent generators 47return sorted(value)[len(value) // 2] 48 49 50@app.template_filter("set") 51def set_filter(value): 52return set(value) 53 54 55@app.template_global() 56def modify_query(**new_values): 57args = flask.request.args.copy() 58for key, value in new_values.items(): 59args[key] = value 60 61return f"{flask.request.path}?{urlencode(args)}" 62 63 64@app.context_processor 65def default_variables(): 66return { 67"current_user": db.session.get(User, flask.session.get("username")), 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") 77 78def __init__(self, username, password): 79self.username = username 80self.password_hashed = bcrypt.generate_password_hash(password).decode("utf-8") 81 82 83class Licence(db.Model): 84id = db.Column(db.String(64), primary_key=True) # SPDX identifier 85title = db.Column(db.UnicodeText, nullable=False) # the official name of the licence 86description = db.Column(db.UnicodeText, nullable=False) # brief description of its permissions and restrictions 87info_url = db.Column(db.String(1024), nullable=False) # the URL to a page with general information about the licence 88url = db.Column(db.String(1024), nullable=True) # the URL to a page with the full text of the licence and more information 89pictures = db.relationship("PictureLicence", back_populates="licence") 90free = db.Column(db.Boolean, nullable=False, default=False) # whether the licence is free or not 91logo_url = db.Column(db.String(1024), nullable=True) # URL to the logo of the licence 92pinned = db.Column(db.Boolean, nullable=False, default=False) # whether the licence should be shown at the top of the list 93 94def __init__(self, id, title, description, info_url, url, free, logo_url=None, pinned=False): 95self.id = id 96self.title = title 97self.description = description 98self.info_url = info_url 99self.url = url 100self.free = free 101self.logo_url = logo_url 102self.pinned = pinned 103 104 105class PictureLicence(db.Model): 106id = db.Column(db.Integer, primary_key=True, autoincrement=True) 107 108resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id")) 109licence_id = db.Column(db.String(64), db.ForeignKey("licence.id")) 110 111resource = db.relationship("PictureResource", back_populates="licences") 112licence = db.relationship("Licence", back_populates="pictures") 113 114def __init__(self, resource, licence): 115self.resource = resource 116self.licence = licence 117 118 119class Resource(db.Model): 120__abstract__ = True 121 122id = db.Column(db.Integer, primary_key=True, autoincrement=True) 123title = db.Column(db.UnicodeText, nullable=False) 124description = db.Column(db.UnicodeText, nullable=False) 125timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 126origin_url = db.Column(db.String(2048), nullable=True) # should be left empty if it's original or the source is unknown but public domain 127 128 129class PictureNature(db.Model): 130# Examples: 131# "photo", "paper-scan", "2d-art-photo", "sculpture-photo", "computer-3d", "computer-painting", 132# "computer-line-art", "diagram", "infographic", "text", "map", "chart-graph", "screen-capture", 133# "screen-photo", "pattern", "collage", "ai", and so on 134id = db.Column(db.String(64), primary_key=True) 135description = db.Column(db.UnicodeText, nullable=False) 136resources = db.relationship("PictureResource", back_populates="nature") 137 138def __init__(self, id, description): 139self.id = id 140self.description = description 141 142 143class PictureObjectInheritance(db.Model): 144parent_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 145primary_key=True) 146child_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 147primary_key=True) 148 149parent = db.relationship("PictureObject", foreign_keys=[parent_id], 150back_populates="child_links") 151child = db.relationship("PictureObject", foreign_keys=[child_id], 152back_populates="parent_links") 153 154def __init__(self, parent, child): 155self.parent = parent 156self.child = child 157 158 159class PictureObject(db.Model): 160id = db.Column(db.String(64), primary_key=True) 161description = db.Column(db.UnicodeText, nullable=False) 162 163child_links = db.relationship("PictureObjectInheritance", 164foreign_keys=[PictureObjectInheritance.parent_id], 165back_populates="parent") 166parent_links = db.relationship("PictureObjectInheritance", 167foreign_keys=[PictureObjectInheritance.child_id], 168back_populates="child") 169 170def __init__(self, id, description): 171self.id = id 172self.description = description 173 174 175class PictureRegion(db.Model): 176# This is for picture region annotations 177id = db.Column(db.Integer, primary_key=True, autoincrement=True) 178json = db.Column(sqlalchemy.dialects.postgresql.JSONB, nullable=False) 179 180resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=False) 181object_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), nullable=True) 182 183resource = db.relationship("PictureResource", backref="regions") 184object = db.relationship("PictureObject", backref="regions") 185 186def __init__(self, json, resource, object): 187self.json = json 188self.resource = resource 189self.object = object 190 191 192class PictureResource(Resource): 193# This is only for bitmap pictures. Vectors will be stored under a different model 194# File name is the ID in the picture directory under data, without an extension 195file_format = db.Column(db.String(64), nullable=False) # MIME type 196width = db.Column(db.Integer, nullable=False) 197height = db.Column(db.Integer, nullable=False) 198nature_id = db.Column(db.String(32), db.ForeignKey("picture_nature.id"), nullable=True) 199author_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 200author = db.relationship("User", back_populates="pictures") 201 202nature = db.relationship("PictureNature", back_populates="resources") 203 204replaces_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=True) 205replaced_by_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 206nullable=True) 207 208replaces = db.relationship("PictureResource", remote_side="PictureResource.id", 209foreign_keys=[replaces_id], back_populates="replaced_by") 210replaced_by = db.relationship("PictureResource", remote_side="PictureResource.id", 211foreign_keys=[replaced_by_id]) 212 213licences = db.relationship("PictureLicence", back_populates="resource") 214 215def __init__(self, title, author, description, origin_url, licence_ids, mime, nature=None, 216replaces=None): 217self.title = title 218self.author = author 219self.description = description 220self.origin_url = origin_url 221self.file_format = mime 222self.width = self.height = 0 223self.nature = nature 224db.session.add(self) 225db.session.commit() 226for licence_id in licence_ids: 227joiner = PictureLicence(self, db.session.get(Licence, licence_id)) 228db.session.add(joiner) 229if replaces is not None: 230self.replaces = replaces 231replaces.replaced_by = self 232 233def put_annotations(self, json): 234# Delete all previous annotations 235db.session.query(PictureRegion).filter_by(resource_id=self.id).delete() 236 237for region in json: 238object_id = region["object"] 239picture_object = db.session.get(PictureObject, object_id) 240 241region_data = { 242"type": region["type"], 243"shape": region["shape"], 244} 245 246region_row = PictureRegion(region_data, self, picture_object) 247db.session.add(region_row) 248 249 250@app.route("/") 251def index(): 252return flask.render_template("home.html", resources=PictureResource.query.order_by(db.func.random()).limit(10).all()) 253 254 255@app.route("/accounts/") 256def accounts(): 257return flask.render_template("login.html") 258 259 260@app.route("/login", methods=["POST"]) 261def login(): 262username = flask.request.form["username"] 263password = flask.request.form["password"] 264 265user = db.session.get(User, username) 266 267if user is None: 268flask.flash("This username is not registered.") 269return flask.redirect("/accounts") 270 271if not bcrypt.check_password_hash(user.password_hashed, password): 272flask.flash("Incorrect password.") 273return flask.redirect("/accounts") 274 275flask.flash("You have been logged in.") 276 277flask.session["username"] = username 278return flask.redirect("/") 279 280 281@app.route("/logout") 282def logout(): 283flask.session.pop("username", None) 284flask.flash("You have been logged out.") 285return flask.redirect("/") 286 287 288@app.route("/signup", methods=["POST"]) 289def signup(): 290username = flask.request.form["username"] 291password = flask.request.form["password"] 292 293if db.session.get(User, username) is not None: 294flask.flash("This username is already taken.") 295return flask.redirect("/accounts") 296 297if set(username) > set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"): 298flask.flash("Usernames can only contain the Latin alphabet, digits, hyphens, and underscores.") 299return flask.redirect("/accounts") 300 301if len(username) < 3 or len(username) > 32: 302flask.flash("Usernames must be between 3 and 32 characters long.") 303return flask.redirect("/accounts") 304 305if len(password) < 6: 306flask.flash("Passwords must be at least 6 characters long.") 307return flask.redirect("/accounts") 308 309user = User(username, password) 310db.session.add(user) 311db.session.commit() 312 313flask.session["username"] = username 314 315flask.flash("You have been registered and logged in.") 316 317return flask.redirect("/") 318 319 320@app.route("/profile", defaults={"username": None}) 321@app.route("/profile/<username>") 322def profile(username): 323if username is None: 324if "username" in flask.session: 325return flask.redirect("/profile/" + flask.session["username"]) 326else: 327flask.flash("Please log in to perform this action.") 328return flask.redirect("/accounts") 329 330user = db.session.get(User, username) 331if user is None: 332flask.abort(404) 333 334return flask.render_template("profile.html", user=user) 335 336 337@app.route("/object/<id>") 338def has_object(id): 339object_ = db.session.get(PictureObject, id) 340if object_ is None: 341flask.abort(404) 342 343query = db.session.query(PictureResource).join(PictureRegion).filter(PictureRegion.object_id == id) 344 345page = int(flask.request.args.get("page", 1)) 346per_page = int(flask.request.args.get("per_page", 16)) 347 348resources = query.paginate(page=page, per_page=per_page) 349 350return flask.render_template("object.html", object=object_, resources=resources, page_number=page, 351page_length=per_page, num_pages=resources.pages, prev_page=resources.prev_num, 352next_page=resources.next_num, PictureRegion=PictureRegion) 353 354 355@app.route("/upload") 356def upload(): 357if "username" not in flask.session: 358flask.flash("Log in to upload pictures.") 359return flask.redirect("/accounts") 360 361licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), Licence.title).all() 362 363types = PictureNature.query.all() 364 365return flask.render_template("upload.html", licences=licences, types=types) 366 367 368@app.route("/upload", methods=["POST"]) 369def upload_post(): 370title = flask.request.form["title"] 371description = flask.request.form["description"] 372origin_url = flask.request.form["origin_url"] 373author = db.session.get(User, flask.session.get("username")) 374licence_ids = flask.request.form.getlist("licence") 375nature_id = flask.request.form["nature"] 376 377if author is None: 378flask.abort(401) 379 380file = flask.request.files["file"] 381 382if not file or not file.filename: 383flask.flash("Select a file") 384return flask.redirect(flask.request.url) 385 386if not file.mimetype.startswith("image/"): 387flask.flash("Only images are supported") 388return flask.redirect(flask.request.url) 389 390if not title: 391flask.flash("Enter a title") 392return flask.redirect(flask.request.url) 393 394if not description: 395description = "" 396 397if not nature_id: 398flask.flash("Select a picture type") 399return flask.redirect(flask.request.url) 400 401if not licence_ids: 402flask.flash("Select licences") 403return flask.redirect(flask.request.url) 404 405licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 406if not any(licence.free for licence in licences): 407flask.flash("Select at least one free licence") 408return flask.redirect(flask.request.url) 409 410resource = PictureResource(title, author, description, origin_url, licence_ids, file.mimetype, 411db.session.get(PictureNature, nature_id)) 412db.session.add(resource) 413db.session.commit() 414file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 415pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 416resource.width, resource.height = pil_image.size 417db.session.commit() 418 419if flask.request.form.get("annotations"): 420try: 421resource.put_annotations(json.loads(flask.request.form.get("annotations"))) 422db.session.commit() 423except json.JSONDecodeError: 424flask.flash("Invalid annotations") 425 426flask.flash("Picture uploaded successfully") 427 428return flask.redirect("/picture/" + str(resource.id)) 429 430 431@app.route("/picture/<int:id>/") 432def picture(id): 433resource = db.session.get(PictureResource, id) 434if resource is None: 435flask.abort(404) 436 437image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 438 439return flask.render_template("picture.html", resource=resource, 440file_extension=mimetypes.guess_extension(resource.file_format), 441size=image.size) 442 443 444 445@app.route("/picture/<int:id>/annotate") 446def annotate_picture(id): 447resource = db.session.get(PictureResource, id) 448if resource is None: 449flask.abort(404) 450 451current_user = db.session.get(User, flask.session.get("username")) 452if current_user is None: 453flask.abort(401) 454if resource.author != current_user and not current_user.admin: 455flask.abort(403) 456 457return flask.render_template("picture-annotation.html", resource=resource, 458file_extension=mimetypes.guess_extension(resource.file_format)) 459 460 461@app.route("/picture/<int:id>/put-annotations-form") 462def put_annotations_form(id): 463resource = db.session.get(PictureResource, id) 464if resource is None: 465flask.abort(404) 466 467current_user = db.session.get(User, flask.session.get("username")) 468if current_user is None: 469flask.abort(401) 470 471if resource.author != current_user and not current_user.admin: 472flask.abort(403) 473 474return flask.render_template("put-annotations-form.html", resource=resource) 475 476 477@app.route("/picture/<int:id>/put-annotations-form", methods=["POST"]) 478def put_annotations_form_post(id): 479resource = db.session.get(PictureResource, id) 480if resource is None: 481flask.abort(404) 482 483current_user = db.session.get(User, flask.session.get("username")) 484if current_user is None: 485flask.abort(401) 486 487if resource.author != current_user and not current_user.admin: 488flask.abort(403) 489 490resource.put_annotations(json.loads(flask.request.form["annotations"])) 491 492db.session.commit() 493 494return flask.redirect("/picture/" + str(resource.id)) 495 496 497 498@app.route("/picture/<int:id>/save-annotations", methods=["POST"]) 499def save_annotations(id): 500resource = db.session.get(PictureResource, id) 501if resource is None: 502flask.abort(404) 503 504current_user = db.session.get(User, flask.session.get("username")) 505if resource.author != current_user and not current_user.admin: 506flask.abort(403) 507 508resource.put_annotations(flask.request.json) 509 510db.session.commit() 511 512response = flask.make_response() 513response.status_code = 204 514return response 515 516 517@app.route("/picture/<int:id>/get-annotations") 518def get_annotations(id): 519resource = db.session.get(PictureResource, id) 520if resource is None: 521flask.abort(404) 522 523regions = db.session.query(PictureRegion).filter_by(resource_id=id).all() 524 525regions_json = [] 526 527for region in regions: 528regions_json.append({ 529"object": region.object_id, 530"type": region.json["type"], 531"shape": region.json["shape"], 532}) 533 534return flask.jsonify(regions_json) 535 536 537@app.route("/picture/<int:id>/delete") 538def delete_picture(id): 539resource = db.session.get(PictureResource, id) 540if resource is None: 541flask.abort(404) 542 543current_user = db.session.get(User, flask.session.get("username")) 544if current_user is None: 545flask.abort(401) 546 547if resource.author != current_user and not current_user.admin: 548flask.abort(403) 549 550PictureLicence.query.filter_by(resource=resource).delete() 551PictureRegion.query.filter_by(resource=resource).delete() 552db.session.delete(resource) 553db.session.commit() 554 555return flask.redirect("/") 556 557 558@app.route("/picture/<int:id>/edit-metadata") 559def edit_picture(id): 560resource = db.session.get(PictureResource, id) 561if resource is None: 562flask.abort(404) 563 564current_user = db.session.get(User, flask.session.get("username")) 565if current_user is None: 566flask.abort(401) 567 568if resource.author != current_user and not current_user.admin: 569flask.abort(403) 570 571licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), Licence.title).all() 572 573types = PictureNature.query.all() 574 575return flask.render_template("edit-picture.html", resource=resource, licences=licences, types=types, 576PictureLicence=PictureLicence) 577 578 579@app.route("/picture/<int:id>/edit-metadata", methods=["POST"]) 580def edit_picture_post(id): 581resource = db.session.get(PictureResource, id) 582if resource is None: 583flask.abort(404) 584 585current_user = db.session.get(User, flask.session.get("username")) 586if current_user is None: 587flask.abort(401) 588 589if resource.author != current_user and not current_user.admin: 590flask.abort(403) 591 592title = flask.request.form["title"] 593description = flask.request.form["description"] 594origin_url = flask.request.form["origin_url"] 595licence_ids = flask.request.form.getlist("licence") 596nature_id = flask.request.form["nature"] 597 598if not title: 599flask.flash("Enter a title") 600return flask.redirect(flask.request.url) 601 602if not description: 603description = "" 604 605if not nature_id: 606flask.flash("Select a picture type") 607return flask.redirect(flask.request.url) 608 609if not licence_ids: 610flask.flash("Select licences") 611return flask.redirect(flask.request.url) 612 613licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 614if not any(licence.free for licence in licences): 615flask.flash("Select at least one free licence") 616return flask.redirect(flask.request.url) 617 618resource.title = title 619resource.description = description 620resource.origin_url = origin_url 621for licence_id in licence_ids: 622joiner = PictureLicence(resource, db.session.get(Licence, licence_id)) 623db.session.add(joiner) 624resource.nature = db.session.get(PictureNature, nature_id) 625 626db.session.commit() 627 628return flask.redirect("/picture/" + str(resource.id)) 629 630 631@app.route("/picture/<int:id>/copy") 632def copy_picture(id): 633resource = db.session.get(PictureResource, id) 634if resource is None: 635flask.abort(404) 636 637current_user = db.session.get(User, flask.session.get("username")) 638if current_user is None: 639flask.abort(401) 640 641new_resource = PictureResource(resource.title, current_user, resource.description, resource.origin_url, 642[licence.licence_id for licence in resource.licences], resource.file_format, 643resource.nature) 644 645for region in resource.regions: 646db.session.add(PictureRegion(region.json, new_resource, region.object)) 647 648db.session.commit() 649 650# Create a hard link for the new picture 651old_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 652new_path = path.join(config.DATA_PATH, "pictures", str(new_resource.id)) 653os.link(old_path, new_path) 654 655return flask.redirect("/picture/" + str(new_resource.id)) 656 657 658@app.route("/query-pictures", methods=["POST"]) # sadly GET can't have a body 659def query_pictures(): 660offset = int(flask.request.args.get("offset", 0)) 661limit = int(flask.request.args.get("limit", 16)) 662ordering = flask.request.args.get("ordering", "date-desc") 663 664yaml_parser = yaml.YAML() 665query_data = yaml_parser.load(flask.request.data) or {} 666 667query = db.session.query(PictureResource) 668 669requirement_conditions = { 670"has_object": lambda value: PictureResource.regions.any( 671PictureRegion.object_id.in_(value)), 672"nature": lambda value: PictureResource.nature_id.in_(value), 673"licence": lambda value: PictureResource.licences.any( 674PictureLicence.licence_id.in_(value)), 675"author": lambda value: PictureResource.author_name.in_(value), 676"title": lambda value: PictureResource.title.ilike(value), 677"description": lambda value: PictureResource.description.ilike(value), 678"origin_url": lambda value: db.func.lower(db.func.substr( 679PictureResource.origin_url, 680db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 681)).in_(value), 682"above_width": lambda value: PictureResource.width >= value, 683"below_width": lambda value: PictureResource.width <= value, 684"above_height": lambda value: PictureResource.height >= value, 685"below_height": lambda value: PictureResource.height <= value, 686"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 687value), 688"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 689value) 690} 691if "want" in query_data: 692for i in query_data["want"]: 693requirement, value = list(i.items())[0] 694condition = requirement_conditions.get(requirement) 695if condition: 696query = query.filter(condition(value)) 697if "exclude" in query_data: 698for i in query_data["exclude"]: 699requirement, value = list(i.items())[0] 700condition = requirement_conditions.get(requirement) 701if condition: 702query = query.filter(~condition(value)) 703if not query_data.get("include_obsolete", False): 704query = query.filter(PictureResource.replaced_by_id.is_(None)) 705 706match ordering: 707case "date-desc": 708query = query.order_by(PictureResource.timestamp.desc()) 709case "date-asc": 710query = query.order_by(PictureResource.timestamp.asc()) 711case "title-asc": 712query = query.order_by(PictureResource.title.asc()) 713case "title-desc": 714query = query.order_by(PictureResource.title.desc()) 715case "random": 716query = query.order_by(db.func.random()) 717case "number-regions-desc": 718query = query.order_by(db.func.count(PictureResource.regions).desc()) 719case "number-regions-asc": 720query = query.order_by(db.func.count(PictureResource.regions).asc()) 721 722query = query.offset(offset).limit(limit) 723resources = query.all() 724 725json_response = { 726"date_generated": datetime.utcnow().timestamp(), 727"resources": [], 728"offset": offset, 729"limit": limit, 730} 731 732json_resources = json_response["resources"] 733 734for resource in resources: 735json_resource = { 736"id": resource.id, 737"title": resource.title, 738"description": resource.description, 739"timestamp": resource.timestamp.timestamp(), 740"origin_url": resource.origin_url, 741"author": resource.author_name, 742"file_format": resource.file_format, 743"width": resource.width, 744"height": resource.height, 745"nature": resource.nature_id, 746"licences": [licence.licence_id for licence in resource.licences], 747"replaces": resource.replaces_id, 748"replaced_by": resource.replaced_by_id, 749"regions": [], 750"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 751} 752for region in resource.regions: 753json_resource["regions"].append({ 754"object": region.object_id, 755"type": region.json["type"], 756"shape": region.json["shape"], 757}) 758 759json_resources.append(json_resource) 760 761response = flask.jsonify(json_response) 762response.headers["Content-Type"] = "application/json" 763return response 764 765 766@app.route("/raw/picture/<int:id>") 767def raw_picture(id): 768resource = db.session.get(PictureResource, id) 769if resource is None: 770flask.abort(404) 771 772response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), str(resource.id)) 773response.mimetype = resource.file_format 774 775return response 776 777 778@app.route("/api/object-types") 779def object_types(): 780objects = db.session.query(PictureObject).all() 781return flask.jsonify({object.id: object.description for object in objects}) 782