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", 217foreign_keys=[copied_from_id], back_populates="copies", 218post_update=True) 219 220copies = db.relationship("PictureResource", remote_side="PictureResource.id", 221foreign_keys=[copied_from_id], post_update=True) 222 223licences = db.relationship("PictureLicence", back_populates="resource") 224 225def __init__(self, title, author, description, origin_url, licence_ids, mime, nature=None): 226self.title = title 227self.author = author 228self.description = description 229self.origin_url = origin_url 230self.file_format = mime 231self.width = self.height = 0 232self.nature = nature 233db.session.add(self) 234db.session.commit() 235for licence_id in licence_ids: 236joiner = PictureLicence(self, db.session.get(Licence, licence_id)) 237db.session.add(joiner) 238 239def put_annotations(self, json): 240# Delete all previous annotations 241db.session.query(PictureRegion).filter_by(resource_id=self.id).delete() 242 243for region in json: 244object_id = region["object"] 245picture_object = db.session.get(PictureObject, object_id) 246 247region_data = { 248"type": region["type"], 249"shape": region["shape"], 250} 251 252region_row = PictureRegion(region_data, self, picture_object) 253db.session.add(region_row) 254 255 256@app.route("/") 257def index(): 258return flask.render_template("home.html", resources=PictureResource.query.order_by(db.func.random()).limit(10).all()) 259 260 261@app.route("/accounts/") 262def accounts(): 263return flask.render_template("login.html") 264 265 266@app.route("/login", methods=["POST"]) 267def login(): 268username = flask.request.form["username"] 269password = flask.request.form["password"] 270 271user = db.session.get(User, username) 272 273if user is None: 274flask.flash("This username is not registered.") 275return flask.redirect("/accounts") 276 277if not bcrypt.check_password_hash(user.password_hashed, password): 278flask.flash("Incorrect password.") 279return flask.redirect("/accounts") 280 281flask.flash("You have been logged in.") 282 283flask.session["username"] = username 284return flask.redirect("/") 285 286 287@app.route("/logout") 288def logout(): 289flask.session.pop("username", None) 290flask.flash("You have been logged out.") 291return flask.redirect("/") 292 293 294@app.route("/signup", methods=["POST"]) 295def signup(): 296username = flask.request.form["username"] 297password = flask.request.form["password"] 298 299if db.session.get(User, username) is not None: 300flask.flash("This username is already taken.") 301return flask.redirect("/accounts") 302 303if set(username) > set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"): 304flask.flash("Usernames can only contain the Latin alphabet, digits, hyphens, and underscores.") 305return flask.redirect("/accounts") 306 307if len(username) < 3 or len(username) > 32: 308flask.flash("Usernames must be between 3 and 32 characters long.") 309return flask.redirect("/accounts") 310 311if len(password) < 6: 312flask.flash("Passwords must be at least 6 characters long.") 313return flask.redirect("/accounts") 314 315user = User(username, password) 316db.session.add(user) 317db.session.commit() 318 319flask.session["username"] = username 320 321flask.flash("You have been registered and logged in.") 322 323return flask.redirect("/") 324 325 326@app.route("/profile", defaults={"username": None}) 327@app.route("/profile/<username>") 328def profile(username): 329if username is None: 330if "username" in flask.session: 331return flask.redirect("/profile/" + flask.session["username"]) 332else: 333flask.flash("Please log in to perform this action.") 334return flask.redirect("/accounts") 335 336user = db.session.get(User, username) 337if user is None: 338flask.abort(404) 339 340return flask.render_template("profile.html", user=user) 341 342 343@app.route("/object/<id>") 344def has_object(id): 345object_ = db.session.get(PictureObject, id) 346if object_ is None: 347flask.abort(404) 348 349query = db.session.query(PictureResource).join(PictureRegion).filter(PictureRegion.object_id == id) 350 351page = int(flask.request.args.get("page", 1)) 352per_page = int(flask.request.args.get("per_page", 16)) 353 354resources = query.paginate(page=page, per_page=per_page) 355 356return flask.render_template("object.html", object=object_, resources=resources, page_number=page, 357page_length=per_page, num_pages=resources.pages, prev_page=resources.prev_num, 358next_page=resources.next_num, PictureRegion=PictureRegion) 359 360 361@app.route("/upload") 362def upload(): 363if "username" not in flask.session: 364flask.flash("Log in to upload pictures.") 365return flask.redirect("/accounts") 366 367licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), Licence.title).all() 368 369types = PictureNature.query.all() 370 371return flask.render_template("upload.html", licences=licences, types=types) 372 373 374@app.route("/upload", methods=["POST"]) 375def upload_post(): 376title = flask.request.form["title"] 377description = flask.request.form["description"] 378origin_url = flask.request.form["origin_url"] 379author = db.session.get(User, flask.session.get("username")) 380licence_ids = flask.request.form.getlist("licence") 381nature_id = flask.request.form["nature"] 382 383if author is None: 384flask.abort(401) 385 386file = flask.request.files["file"] 387 388if not file or not file.filename: 389flask.flash("Select a file") 390return flask.redirect(flask.request.url) 391 392if not file.mimetype.startswith("image/"): 393flask.flash("Only images are supported") 394return flask.redirect(flask.request.url) 395 396if not title: 397flask.flash("Enter a title") 398return flask.redirect(flask.request.url) 399 400if not description: 401description = "" 402 403if not nature_id: 404flask.flash("Select a picture type") 405return flask.redirect(flask.request.url) 406 407if not licence_ids: 408flask.flash("Select licences") 409return flask.redirect(flask.request.url) 410 411licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 412if not any(licence.free for licence in licences): 413flask.flash("Select at least one free licence") 414return flask.redirect(flask.request.url) 415 416resource = PictureResource(title, author, description, origin_url, licence_ids, file.mimetype, 417db.session.get(PictureNature, nature_id)) 418db.session.add(resource) 419db.session.commit() 420file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 421pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 422resource.width, resource.height = pil_image.size 423db.session.commit() 424 425if flask.request.form.get("annotations"): 426try: 427resource.put_annotations(json.loads(flask.request.form.get("annotations"))) 428db.session.commit() 429except json.JSONDecodeError: 430flask.flash("Invalid annotations") 431 432flask.flash("Picture uploaded successfully") 433 434return flask.redirect("/picture/" + str(resource.id)) 435 436 437@app.route("/picture/<int:id>/") 438def picture(id): 439resource = db.session.get(PictureResource, id) 440if resource is None: 441flask.abort(404) 442 443image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 444 445return flask.render_template("picture.html", resource=resource, 446file_extension=mimetypes.guess_extension(resource.file_format), 447size=image.size) 448 449 450 451@app.route("/picture/<int:id>/annotate") 452def annotate_picture(id): 453resource = db.session.get(PictureResource, id) 454if resource is None: 455flask.abort(404) 456 457current_user = db.session.get(User, flask.session.get("username")) 458if current_user is None: 459flask.abort(401) 460if resource.author != current_user and not current_user.admin: 461flask.abort(403) 462 463return flask.render_template("picture-annotation.html", resource=resource, 464file_extension=mimetypes.guess_extension(resource.file_format)) 465 466 467@app.route("/picture/<int:id>/put-annotations-form") 468def put_annotations_form(id): 469resource = db.session.get(PictureResource, id) 470if resource is None: 471flask.abort(404) 472 473current_user = db.session.get(User, flask.session.get("username")) 474if current_user is None: 475flask.abort(401) 476 477if resource.author != current_user and not current_user.admin: 478flask.abort(403) 479 480return flask.render_template("put-annotations-form.html", resource=resource) 481 482 483@app.route("/picture/<int:id>/put-annotations-form", methods=["POST"]) 484def put_annotations_form_post(id): 485resource = db.session.get(PictureResource, id) 486if resource is None: 487flask.abort(404) 488 489current_user = db.session.get(User, flask.session.get("username")) 490if current_user is None: 491flask.abort(401) 492 493if resource.author != current_user and not current_user.admin: 494flask.abort(403) 495 496resource.put_annotations(json.loads(flask.request.form["annotations"])) 497 498db.session.commit() 499 500return flask.redirect("/picture/" + str(resource.id)) 501 502 503 504@app.route("/picture/<int:id>/save-annotations", methods=["POST"]) 505def save_annotations(id): 506resource = db.session.get(PictureResource, id) 507if resource is None: 508flask.abort(404) 509 510current_user = db.session.get(User, flask.session.get("username")) 511if resource.author != current_user and not current_user.admin: 512flask.abort(403) 513 514resource.put_annotations(flask.request.json) 515 516db.session.commit() 517 518response = flask.make_response() 519response.status_code = 204 520return response 521 522 523@app.route("/picture/<int:id>/get-annotations") 524def get_annotations(id): 525resource = db.session.get(PictureResource, id) 526if resource is None: 527flask.abort(404) 528 529regions = db.session.query(PictureRegion).filter_by(resource_id=id).all() 530 531regions_json = [] 532 533for region in regions: 534regions_json.append({ 535"object": region.object_id, 536"type": region.json["type"], 537"shape": region.json["shape"], 538}) 539 540return flask.jsonify(regions_json) 541 542 543@app.route("/picture/<int:id>/delete") 544def delete_picture(id): 545resource = db.session.get(PictureResource, id) 546if resource is None: 547flask.abort(404) 548 549current_user = db.session.get(User, flask.session.get("username")) 550if current_user is None: 551flask.abort(401) 552 553if resource.author != current_user and not current_user.admin: 554flask.abort(403) 555 556PictureLicence.query.filter_by(resource=resource).delete() 557PictureRegion.query.filter_by(resource=resource).delete() 558db.session.delete(resource) 559db.session.commit() 560 561return flask.redirect("/") 562 563 564@app.route("/picture/<int:id>/edit-metadata") 565def edit_picture(id): 566resource = db.session.get(PictureResource, id) 567if resource is None: 568flask.abort(404) 569 570current_user = db.session.get(User, flask.session.get("username")) 571if current_user is None: 572flask.abort(401) 573 574if resource.author != current_user and not current_user.admin: 575flask.abort(403) 576 577licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), Licence.title).all() 578 579types = PictureNature.query.all() 580 581return flask.render_template("edit-picture.html", resource=resource, licences=licences, types=types, 582PictureLicence=PictureLicence) 583 584 585@app.route("/picture/<int:id>/edit-metadata", methods=["POST"]) 586def edit_picture_post(id): 587resource = db.session.get(PictureResource, id) 588if resource is None: 589flask.abort(404) 590 591current_user = db.session.get(User, flask.session.get("username")) 592if current_user is None: 593flask.abort(401) 594 595if resource.author != current_user and not current_user.admin: 596flask.abort(403) 597 598title = flask.request.form["title"] 599description = flask.request.form["description"] 600origin_url = flask.request.form["origin_url"] 601licence_ids = flask.request.form.getlist("licence") 602nature_id = flask.request.form["nature"] 603 604if not title: 605flask.flash("Enter a title") 606return flask.redirect(flask.request.url) 607 608if not description: 609description = "" 610 611if not nature_id: 612flask.flash("Select a picture type") 613return flask.redirect(flask.request.url) 614 615if not licence_ids: 616flask.flash("Select licences") 617return flask.redirect(flask.request.url) 618 619licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 620if not any(licence.free for licence in licences): 621flask.flash("Select at least one free licence") 622return flask.redirect(flask.request.url) 623 624resource.title = title 625resource.description = description 626resource.origin_url = origin_url 627for licence_id in licence_ids: 628joiner = PictureLicence(resource, db.session.get(Licence, licence_id)) 629db.session.add(joiner) 630resource.nature = db.session.get(PictureNature, nature_id) 631 632db.session.commit() 633 634return flask.redirect("/picture/" + str(resource.id)) 635 636 637@app.route("/picture/<int:id>/copy") 638def copy_picture(id): 639resource = db.session.get(PictureResource, id) 640if resource is None: 641flask.abort(404) 642 643current_user = db.session.get(User, flask.session.get("username")) 644if current_user is None: 645flask.abort(401) 646 647new_resource = PictureResource(resource.title, current_user, resource.description, resource.origin_url, 648[licence.licence_id for licence in resource.licences], resource.file_format, 649resource.nature) 650 651for region in resource.regions: 652db.session.add(PictureRegion(region.json, new_resource, region.object)) 653 654db.session.commit() 655 656# Create a hard link for the new picture 657old_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 658new_path = path.join(config.DATA_PATH, "pictures", str(new_resource.id)) 659os.link(old_path, new_path) 660 661new_resource.width = resource.width 662new_resource.height = resource.height 663new_resource.copied_from = resource 664 665db.session.commit() 666 667return flask.redirect("/picture/" + str(new_resource.id)) 668 669 670@app.route("/query-pictures", methods=["POST"]) # sadly GET can't have a body 671def query_pictures(): 672offset = int(flask.request.args.get("offset", 0)) 673limit = int(flask.request.args.get("limit", 16)) 674ordering = flask.request.args.get("ordering", "date-desc") 675 676yaml_parser = yaml.YAML() 677query_data = yaml_parser.load(flask.request.data) or {} 678 679query = db.session.query(PictureResource) 680 681requirement_conditions = { 682"has_object": lambda value: PictureResource.regions.any( 683PictureRegion.object_id.in_(value)), 684"nature": lambda value: PictureResource.nature_id.in_(value), 685"licence": lambda value: PictureResource.licences.any( 686PictureLicence.licence_id.in_(value)), 687"author": lambda value: PictureResource.author_name.in_(value), 688"title": lambda value: PictureResource.title.ilike(value), 689"description": lambda value: PictureResource.description.ilike(value), 690"origin_url": lambda value: db.func.lower(db.func.substr( 691PictureResource.origin_url, 692db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 693)).in_(value), 694"above_width": lambda value: PictureResource.width >= value, 695"below_width": lambda value: PictureResource.width <= value, 696"above_height": lambda value: PictureResource.height >= value, 697"below_height": lambda value: PictureResource.height <= value, 698"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 699value), 700"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 701value) 702} 703if "want" in query_data: 704for i in query_data["want"]: 705requirement, value = list(i.items())[0] 706condition = requirement_conditions.get(requirement) 707if condition: 708query = query.filter(condition(value)) 709if "exclude" in query_data: 710for i in query_data["exclude"]: 711requirement, value = list(i.items())[0] 712condition = requirement_conditions.get(requirement) 713if condition: 714query = query.filter(~condition(value)) 715if not query_data.get("include_obsolete", False): 716query = query.filter(PictureResource.replaced_by_id.is_(None)) 717 718match ordering: 719case "date-desc": 720query = query.order_by(PictureResource.timestamp.desc()) 721case "date-asc": 722query = query.order_by(PictureResource.timestamp.asc()) 723case "title-asc": 724query = query.order_by(PictureResource.title.asc()) 725case "title-desc": 726query = query.order_by(PictureResource.title.desc()) 727case "random": 728query = query.order_by(db.func.random()) 729case "number-regions-desc": 730query = query.order_by(db.func.count(PictureResource.regions).desc()) 731case "number-regions-asc": 732query = query.order_by(db.func.count(PictureResource.regions).asc()) 733 734query = query.offset(offset).limit(limit) 735resources = query.all() 736 737json_response = { 738"date_generated": datetime.utcnow().timestamp(), 739"resources": [], 740"offset": offset, 741"limit": limit, 742} 743 744json_resources = json_response["resources"] 745 746for resource in resources: 747json_resource = { 748"id": resource.id, 749"title": resource.title, 750"description": resource.description, 751"timestamp": resource.timestamp.timestamp(), 752"origin_url": resource.origin_url, 753"author": resource.author_name, 754"file_format": resource.file_format, 755"width": resource.width, 756"height": resource.height, 757"nature": resource.nature_id, 758"licences": [licence.licence_id for licence in resource.licences], 759"replaces": resource.replaces_id, 760"replaced_by": resource.replaced_by_id, 761"regions": [], 762"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 763} 764for region in resource.regions: 765json_resource["regions"].append({ 766"object": region.object_id, 767"type": region.json["type"], 768"shape": region.json["shape"], 769}) 770 771json_resources.append(json_resource) 772 773response = flask.jsonify(json_response) 774response.headers["Content-Type"] = "application/json" 775return response 776 777 778@app.route("/raw/picture/<int:id>") 779def raw_picture(id): 780resource = db.session.get(PictureResource, id) 781if resource is None: 782flask.abort(404) 783 784response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), str(resource.id)) 785response.mimetype = resource.file_format 786 787return response 788 789 790@app.route("/api/object-types") 791def object_types(): 792objects = db.session.query(PictureObject).all() 793return flask.jsonify({object.id: object.description for object in objects}) 794