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(64), 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(): 298if "username" not in flask.session: 299flask.flash("Log in to upload pictures.") 300return flask.redirect("/accounts") 301 302licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), Licence.title).all() 303 304types = PictureNature.query.all() 305 306return flask.render_template("upload.html", licences=licences, types=types) 307 308 309@app.route("/upload", methods=["POST"]) 310def upload_post(): 311title = flask.request.form["title"] 312description = flask.request.form["description"] 313origin_url = flask.request.form["origin_url"] 314author = db.session.get(User, flask.session.get("username")) 315licence_ids = flask.request.form.getlist("licence") 316nature_id = flask.request.form["nature"] 317 318if author is None: 319flask.abort(401) 320 321file = flask.request.files["file"] 322 323if not file or not file.filename: 324flask.flash("Select a file") 325return flask.redirect(flask.request.url) 326 327if not file.mimetype.startswith("image/"): 328flask.flash("Only images are supported") 329return flask.redirect(flask.request.url) 330 331if not title: 332flask.flash("Enter a title") 333return flask.redirect(flask.request.url) 334 335if not description: 336description = "" 337 338if not nature_id: 339flask.flash("Select a picture type") 340return flask.redirect(flask.request.url) 341 342if not licence_ids: 343flask.flash("Select licences") 344return flask.redirect(flask.request.url) 345 346licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 347if not any(licence.free for licence in licences): 348flask.flash("Select at least one free licence") 349return flask.redirect(flask.request.url) 350 351resource = PictureResource(title, author, description, origin_url, licence_ids, file.mimetype, 352db.session.get(PictureNature, nature_id)) 353db.session.add(resource) 354db.session.commit() 355file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 356 357flask.flash("Picture uploaded successfully") 358 359return flask.redirect("/picture/" + str(resource.id)) 360 361 362@app.route("/picture/<int:id>/") 363def picture(id): 364resource = db.session.get(PictureResource, id) 365if resource is None: 366flask.abort(404) 367 368image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 369 370return flask.render_template("picture.html", resource=resource, 371file_extension=mimetypes.guess_extension(resource.file_format), 372size=image.size) 373 374 375 376@app.route("/picture/<int:id>/annotate") 377def annotate_picture(id): 378resource = db.session.get(PictureResource, id) 379current_user = db.session.get(User, flask.session.get("username")) 380if current_user is None: 381flask.abort(401) 382if resource.author != current_user and not current_user.admin: 383flask.abort(403) 384 385if resource is None: 386flask.abort(404) 387 388return flask.render_template("picture-annotation.html", resource=resource, 389file_extension=mimetypes.guess_extension(resource.file_format)) 390 391 392@app.route("/picture/<int:id>/save-annotations", methods=["POST"]) 393def save_annotations(id): 394resource = db.session.get(PictureResource, id) 395if resource is None: 396flask.abort(404) 397 398current_user = db.session.get(User, flask.session.get("username")) 399if resource.author != current_user and not current_user.admin: 400flask.abort(403) 401 402# Delete all previous annotations 403db.session.query(PictureRegion).filter_by(resource_id=id).delete() 404 405json = flask.request.json 406for region in json: 407object_id = region["object"] 408picture_object = db.session.get(PictureObject, object_id) 409 410region_data = { 411"type": region["type"], 412"shape": region["shape"], 413} 414 415region_row = PictureRegion(region_data, resource, picture_object) 416db.session.add(region_row) 417 418 419db.session.commit() 420 421response = flask.make_response() 422response.status_code = 204 423return response 424 425 426@app.route("/picture/<int:id>/get-annotations") 427def get_annotations(id): 428resource = db.session.get(PictureResource, id) 429if resource is None: 430flask.abort(404) 431 432regions = db.session.query(PictureRegion).filter_by(resource_id=id).all() 433 434regions_json = [] 435 436for region in regions: 437regions_json.append({ 438"object": region.object_id, 439"type": region.json["type"], 440"shape": region.json["shape"], 441}) 442 443return flask.jsonify(regions_json) 444 445 446@app.route("/raw/picture/<int:id>") 447def raw_picture(id): 448resource = db.session.get(PictureResource, id) 449if resource is None: 450flask.abort(404) 451 452response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), str(resource.id)) 453response.mimetype = resource.file_format 454 455return response 456 457 458@app.route("/api/object-types") 459def object_types(): 460objects = db.session.query(PictureObject).all() 461return flask.jsonify({object.id: object.description for object in objects}) 462