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