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