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