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