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