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 441current_user = db.session.get(User, flask.session.get("username")) 442have_permission = current_user and (current_user == resource.author or current_user.admin) 443 444return flask.render_template("picture.html", resource=resource, 445file_extension=mimetypes.guess_extension(resource.file_format), 446size=image.size, copies=resource.copies, have_permission=have_permission) 447 448 449 450@app.route("/picture/<int:id>/annotate") 451def annotate_picture(id): 452resource = db.session.get(PictureResource, id) 453if resource is None: 454flask.abort(404) 455 456current_user = db.session.get(User, flask.session.get("username")) 457if current_user is None: 458flask.abort(401) 459if resource.author != current_user and not current_user.admin: 460flask.abort(403) 461 462return flask.render_template("picture-annotation.html", resource=resource, 463file_extension=mimetypes.guess_extension(resource.file_format)) 464 465 466@app.route("/picture/<int:id>/put-annotations-form") 467def put_annotations_form(id): 468resource = db.session.get(PictureResource, id) 469if resource is None: 470flask.abort(404) 471 472current_user = db.session.get(User, flask.session.get("username")) 473if current_user is None: 474flask.abort(401) 475 476if resource.author != current_user and not current_user.admin: 477flask.abort(403) 478 479return flask.render_template("put-annotations-form.html", resource=resource) 480 481 482@app.route("/picture/<int:id>/put-annotations-form", methods=["POST"]) 483def put_annotations_form_post(id): 484resource = db.session.get(PictureResource, id) 485if resource is None: 486flask.abort(404) 487 488current_user = db.session.get(User, flask.session.get("username")) 489if current_user is None: 490flask.abort(401) 491 492if resource.author != current_user and not current_user.admin: 493flask.abort(403) 494 495resource.put_annotations(json.loads(flask.request.form["annotations"])) 496 497db.session.commit() 498 499return flask.redirect("/picture/" + str(resource.id)) 500 501 502 503@app.route("/picture/<int:id>/save-annotations", methods=["POST"]) 504def save_annotations(id): 505resource = db.session.get(PictureResource, id) 506if resource is None: 507flask.abort(404) 508 509current_user = db.session.get(User, flask.session.get("username")) 510if resource.author != current_user and not current_user.admin: 511flask.abort(403) 512 513resource.put_annotations(flask.request.json) 514 515db.session.commit() 516 517response = flask.make_response() 518response.status_code = 204 519return response 520 521 522@app.route("/picture/<int:id>/get-annotations") 523def get_annotations(id): 524resource = db.session.get(PictureResource, id) 525if resource is None: 526flask.abort(404) 527 528regions = db.session.query(PictureRegion).filter_by(resource_id=id).all() 529 530regions_json = [] 531 532for region in regions: 533regions_json.append({ 534"object": region.object_id, 535"type": region.json["type"], 536"shape": region.json["shape"], 537}) 538 539return flask.jsonify(regions_json) 540 541 542@app.route("/picture/<int:id>/delete") 543def delete_picture(id): 544resource = db.session.get(PictureResource, id) 545if resource is None: 546flask.abort(404) 547 548current_user = db.session.get(User, flask.session.get("username")) 549if current_user is None: 550flask.abort(401) 551 552if resource.author != current_user and not current_user.admin: 553flask.abort(403) 554 555PictureLicence.query.filter_by(resource=resource).delete() 556PictureRegion.query.filter_by(resource=resource).delete() 557db.session.delete(resource) 558db.session.commit() 559 560return flask.redirect("/") 561 562 563@app.route("/picture/<int:id>/edit-metadata") 564def edit_picture(id): 565resource = db.session.get(PictureResource, id) 566if resource is None: 567flask.abort(404) 568 569current_user = db.session.get(User, flask.session.get("username")) 570if current_user is None: 571flask.abort(401) 572 573if resource.author != current_user and not current_user.admin: 574flask.abort(403) 575 576licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), Licence.title).all() 577 578types = PictureNature.query.all() 579 580return flask.render_template("edit-picture.html", resource=resource, licences=licences, types=types, 581PictureLicence=PictureLicence) 582 583 584@app.route("/picture/<int:id>/edit-metadata", methods=["POST"]) 585def edit_picture_post(id): 586resource = db.session.get(PictureResource, id) 587if resource is None: 588flask.abort(404) 589 590current_user = db.session.get(User, flask.session.get("username")) 591if current_user is None: 592flask.abort(401) 593 594if resource.author != current_user and not current_user.admin: 595flask.abort(403) 596 597title = flask.request.form["title"] 598description = flask.request.form["description"] 599origin_url = flask.request.form["origin_url"] 600licence_ids = flask.request.form.getlist("licence") 601nature_id = flask.request.form["nature"] 602 603if not title: 604flask.flash("Enter a title") 605return flask.redirect(flask.request.url) 606 607if not description: 608description = "" 609 610if not nature_id: 611flask.flash("Select a picture type") 612return flask.redirect(flask.request.url) 613 614if not licence_ids: 615flask.flash("Select licences") 616return flask.redirect(flask.request.url) 617 618licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 619if not any(licence.free for licence in licences): 620flask.flash("Select at least one free licence") 621return flask.redirect(flask.request.url) 622 623resource.title = title 624resource.description = description 625resource.origin_url = origin_url 626for licence_id in licence_ids: 627joiner = PictureLicence(resource, db.session.get(Licence, licence_id)) 628db.session.add(joiner) 629resource.nature = db.session.get(PictureNature, nature_id) 630 631db.session.commit() 632 633return flask.redirect("/picture/" + str(resource.id)) 634 635 636@app.route("/picture/<int:id>/copy") 637def copy_picture(id): 638resource = db.session.get(PictureResource, id) 639if resource is None: 640flask.abort(404) 641 642current_user = db.session.get(User, flask.session.get("username")) 643if current_user is None: 644flask.abort(401) 645 646new_resource = PictureResource(resource.title, current_user, resource.description, resource.origin_url, 647[licence.licence_id for licence in resource.licences], resource.file_format, 648resource.nature) 649 650for region in resource.regions: 651db.session.add(PictureRegion(region.json, new_resource, region.object)) 652 653db.session.commit() 654 655# Create a hard link for the new picture 656old_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 657new_path = path.join(config.DATA_PATH, "pictures", str(new_resource.id)) 658os.link(old_path, new_path) 659 660new_resource.width = resource.width 661new_resource.height = resource.height 662new_resource.copied_from = resource 663 664db.session.commit() 665 666return flask.redirect("/picture/" + str(new_resource.id)) 667 668 669@app.route("/query-pictures", methods=["POST"]) # sadly GET can't have a body 670def query_pictures(): 671offset = int(flask.request.args.get("offset", 0)) 672limit = int(flask.request.args.get("limit", 16)) 673ordering = flask.request.args.get("ordering", "date-desc") 674 675yaml_parser = yaml.YAML() 676query_data = yaml_parser.load(flask.request.data) or {} 677 678query = db.session.query(PictureResource) 679 680requirement_conditions = { 681"has_object": lambda value: PictureResource.regions.any( 682PictureRegion.object_id.in_(value)), 683"nature": lambda value: PictureResource.nature_id.in_(value), 684"licence": lambda value: PictureResource.licences.any( 685PictureLicence.licence_id.in_(value)), 686"author": lambda value: PictureResource.author_name.in_(value), 687"title": lambda value: PictureResource.title.ilike(value), 688"description": lambda value: PictureResource.description.ilike(value), 689"origin_url": lambda value: db.func.lower(db.func.substr( 690PictureResource.origin_url, 691db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 692)).in_(value), 693"above_width": lambda value: PictureResource.width >= value, 694"below_width": lambda value: PictureResource.width <= value, 695"above_height": lambda value: PictureResource.height >= value, 696"below_height": lambda value: PictureResource.height <= value, 697"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 698value), 699"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 700value) 701} 702if "want" in query_data: 703for i in query_data["want"]: 704requirement, value = list(i.items())[0] 705condition = requirement_conditions.get(requirement) 706if condition: 707query = query.filter(condition(value)) 708if "exclude" in query_data: 709for i in query_data["exclude"]: 710requirement, value = list(i.items())[0] 711condition = requirement_conditions.get(requirement) 712if condition: 713query = query.filter(~condition(value)) 714if not query_data.get("include_obsolete", False): 715query = query.filter(PictureResource.replaced_by_id.is_(None)) 716 717match ordering: 718case "date-desc": 719query = query.order_by(PictureResource.timestamp.desc()) 720case "date-asc": 721query = query.order_by(PictureResource.timestamp.asc()) 722case "title-asc": 723query = query.order_by(PictureResource.title.asc()) 724case "title-desc": 725query = query.order_by(PictureResource.title.desc()) 726case "random": 727query = query.order_by(db.func.random()) 728case "number-regions-desc": 729query = query.order_by(db.func.count(PictureResource.regions).desc()) 730case "number-regions-asc": 731query = query.order_by(db.func.count(PictureResource.regions).asc()) 732 733query = query.offset(offset).limit(limit) 734resources = query.all() 735 736json_response = { 737"date_generated": datetime.utcnow().timestamp(), 738"resources": [], 739"offset": offset, 740"limit": limit, 741} 742 743json_resources = json_response["resources"] 744 745for resource in resources: 746json_resource = { 747"id": resource.id, 748"title": resource.title, 749"description": resource.description, 750"timestamp": resource.timestamp.timestamp(), 751"origin_url": resource.origin_url, 752"author": resource.author_name, 753"file_format": resource.file_format, 754"width": resource.width, 755"height": resource.height, 756"nature": resource.nature_id, 757"licences": [licence.licence_id for licence in resource.licences], 758"replaces": resource.replaces_id, 759"replaced_by": resource.replaced_by_id, 760"regions": [], 761"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 762} 763for region in resource.regions: 764json_resource["regions"].append({ 765"object": region.object_id, 766"type": region.json["type"], 767"shape": region.json["shape"], 768}) 769 770json_resources.append(json_resource) 771 772response = flask.jsonify(json_response) 773response.headers["Content-Type"] = "application/json" 774return response 775 776 777@app.route("/raw/picture/<int:id>") 778def raw_picture(id): 779resource = db.session.get(PictureResource, id) 780if resource is None: 781flask.abort(404) 782 783response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), str(resource.id)) 784response.mimetype = resource.file_format 785 786return response 787 788 789@app.route("/api/object-types") 790def object_types(): 791objects = db.session.query(PictureObject).all() 792return flask.jsonify({object.id: object.description for object in objects}) 793