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