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_id, licence_id): 114self.resource_id = resource_id 115self.licence_id = licence_id 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.id, 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") 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) 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 416 417if flask.request.form.get("annotations"): 418try: 419resource.put_annotations(json.loads(flask.request.form.get("annotations"))) 420db.session.commit() 421except json.JSONDecodeError: 422flask.flash("Invalid annotations") 423 424flask.flash("Picture uploaded successfully") 425 426return flask.redirect("/picture/" + str(resource.id)) 427 428 429@app.route("/picture/<int:id>/") 430def picture(id): 431resource = db.session.get(PictureResource, id) 432if resource is None: 433flask.abort(404) 434 435image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 436 437return flask.render_template("picture.html", resource=resource, 438file_extension=mimetypes.guess_extension(resource.file_format), 439size=image.size) 440 441 442 443@app.route("/picture/<int:id>/annotate") 444def annotate_picture(id): 445resource = db.session.get(PictureResource, id) 446if resource is None: 447flask.abort(404) 448 449current_user = db.session.get(User, flask.session.get("username")) 450if current_user is None: 451flask.abort(401) 452if resource.author != current_user and not current_user.admin: 453flask.abort(403) 454 455return flask.render_template("picture-annotation.html", resource=resource, 456file_extension=mimetypes.guess_extension(resource.file_format)) 457 458 459@app.route("/picture/<int:id>/put-annotations-form") 460def put_annotations_form(id): 461resource = db.session.get(PictureResource, id) 462if resource is None: 463flask.abort(404) 464 465current_user = db.session.get(User, flask.session.get("username")) 466if current_user is None: 467flask.abort(401) 468 469if resource.author != current_user and not current_user.admin: 470flask.abort(403) 471 472return flask.render_template("put-annotations-form.html", resource=resource) 473 474 475@app.route("/picture/<int:id>/put-annotations-form", methods=["POST"]) 476def put_annotations_form_post(id): 477resource = db.session.get(PictureResource, id) 478if resource is None: 479flask.abort(404) 480 481current_user = db.session.get(User, flask.session.get("username")) 482if current_user is None: 483flask.abort(401) 484 485if resource.author != current_user and not current_user.admin: 486flask.abort(403) 487 488resource.put_annotations(json.loads(flask.request.form["annotations"])) 489 490db.session.commit() 491 492return flask.redirect("/picture/" + str(resource.id)) 493 494 495 496@app.route("/picture/<int:id>/save-annotations", methods=["POST"]) 497def save_annotations(id): 498resource = db.session.get(PictureResource, id) 499if resource is None: 500flask.abort(404) 501 502current_user = db.session.get(User, flask.session.get("username")) 503if resource.author != current_user and not current_user.admin: 504flask.abort(403) 505 506resource.put_annotations(flask.request.json) 507 508db.session.commit() 509 510response = flask.make_response() 511response.status_code = 204 512return response 513 514 515@app.route("/picture/<int:id>/get-annotations") 516def get_annotations(id): 517resource = db.session.get(PictureResource, id) 518if resource is None: 519flask.abort(404) 520 521regions = db.session.query(PictureRegion).filter_by(resource_id=id).all() 522 523regions_json = [] 524 525for region in regions: 526regions_json.append({ 527"object": region.object_id, 528"type": region.json["type"], 529"shape": region.json["shape"], 530}) 531 532return flask.jsonify(regions_json) 533 534 535@app.route("/query-pictures", methods=["POST"]) # sadly GET can't have a body 536def query_pictures(): 537offset = int(flask.request.args.get("offset", 0)) 538limit = int(flask.request.args.get("limit", 16)) 539ordering = flask.request.args.get("ordering", "date-desc") 540 541yaml_parser = yaml.YAML() 542query_data = yaml_parser.load(flask.request.data) or {} 543 544query = db.session.query(PictureResource) 545 546requirement_conditions = { 547"has_object": lambda value: PictureResource.regions.any( 548PictureRegion.object_id.in_(value)), 549"nature": lambda value: PictureResource.nature_id.in_(value), 550"licence": lambda value: PictureResource.licences.any( 551PictureLicence.licence_id.in_(value)), 552"author": lambda value: PictureResource.author_name.in_(value), 553"title": lambda value: PictureResource.title.ilike(value), 554"description": lambda value: PictureResource.description.ilike(value), 555"origin_url": lambda value: db.func.lower(db.func.substr( 556PictureResource.origin_url, 557db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 558)).in_(value), 559"above_width": lambda value: PictureResource.width >= value, 560"below_width": lambda value: PictureResource.width <= value, 561"above_height": lambda value: PictureResource.height >= value, 562"below_height": lambda value: PictureResource.height <= value, 563"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 564value), 565"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 566value) 567} 568if "want" in query_data: 569for i in query_data["want"]: 570requirement, value = list(i.items())[0] 571condition = requirement_conditions.get(requirement) 572if condition: 573query = query.filter(condition(value)) 574if "exclude" in query_data: 575for i in query_data["exclude"]: 576requirement, value = list(i.items())[0] 577condition = requirement_conditions.get(requirement) 578if condition: 579query = query.filter(~condition(value)) 580if not query_data.get("include_obsolete", False): 581query = query.filter(PictureResource.replaced_by_id.is_(None)) 582 583match ordering: 584case "date-desc": 585query = query.order_by(PictureResource.timestamp.desc()) 586case "date-asc": 587query = query.order_by(PictureResource.timestamp.asc()) 588case "title-asc": 589query = query.order_by(PictureResource.title.asc()) 590case "title-desc": 591query = query.order_by(PictureResource.title.desc()) 592case "random": 593query = query.order_by(db.func.random()) 594case "number-regions-desc": 595query = query.order_by(db.func.count(PictureResource.regions).desc()) 596case "number-regions-asc": 597query = query.order_by(db.func.count(PictureResource.regions).asc()) 598 599query = query.offset(offset).limit(limit) 600resources = query.all() 601 602json_response = { 603"date_generated": datetime.utcnow().timestamp(), 604"resources": [], 605"offset": offset, 606"limit": limit, 607} 608 609json_resources = json_response["resources"] 610 611for resource in resources: 612json_resource = { 613"id": resource.id, 614"title": resource.title, 615"description": resource.description, 616"timestamp": resource.timestamp.timestamp(), 617"origin_url": resource.origin_url, 618"author": resource.author_name, 619"file_format": resource.file_format, 620"width": resource.width, 621"height": resource.height, 622"nature": resource.nature_id, 623"licences": [licence.licence_id for licence in resource.licences], 624"replaces": resource.replaces_id, 625"replaced_by": resource.replaced_by_id, 626"regions": [], 627} 628for region in resource.regions: 629json_resource["regions"].append({ 630"object": region.object_id, 631"type": region.json["type"], 632"shape": region.json["shape"], 633}) 634 635json_resources.append(json_resource) 636 637response = flask.jsonify(json_response) 638response.headers["Content-Type"] = "application/json" 639return response 640 641 642@app.route("/raw/picture/<int:id>") 643def raw_picture(id): 644resource = db.session.get(PictureResource, id) 645if resource is None: 646flask.abort(404) 647 648response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), str(resource.id)) 649response.mimetype = resource.file_format 650 651return response 652 653 654@app.route("/api/object-types") 655def object_types(): 656objects = db.session.query(PictureObject).all() 657return flask.jsonify({object.id: object.description for object in objects}) 658