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