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