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