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