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