app.py
Python script, ASCII text executable
1import json 2from datetime import datetime 3from email.policy import default 4from time import perf_counter 5 6import flask 7from flask_sqlalchemy import SQLAlchemy 8from flask_bcrypt import Bcrypt 9from flask_httpauth import HTTPBasicAuth 10from markupsafe import escape, Markup 11from flask_migrate import Migrate, current 12from jinja2_fragments.flask import render_block 13from sqlalchemy import false 14from sqlalchemy.orm import backref 15import sqlalchemy.dialects.postgresql 16from os import path 17import os 18from urllib.parse import urlencode 19import mimetypes 20import ruamel.yaml as yaml 21 22from PIL import Image 23from sqlalchemy.orm.persistence import post_update 24from sqlalchemy.sql.functions import current_user 25 26import config 27import markdown 28 29app = flask.Flask(__name__) 30bcrypt = Bcrypt(app) 31 32app.config["SQLALCHEMY_DATABASE_URI"] = config.DB_URI 33app.config["SECRET_KEY"] = config.DB_PASSWORD 34 35db = SQLAlchemy(app) 36migrate = Migrate(app, db) 37 38 39@app.template_filter("split") 40def split(value, separator=None, maxsplit=-1): 41return value.split(separator, maxsplit) 42 43 44@app.template_filter("median") 45def median(value): 46value = list(value) # prevent generators 47return sorted(value)[len(value) // 2] 48 49 50@app.template_filter("set") 51def set_filter(value): 52return set(value) 53 54 55@app.template_global() 56def modify_query(**new_values): 57args = flask.request.args.copy() 58for key, value in new_values.items(): 59args[key] = value 60 61return f"{flask.request.path}?{urlencode(args)}" 62 63 64@app.context_processor 65def default_variables(): 66return { 67"current_user": db.session.get(User, flask.session.get("username")), 68} 69 70 71with app.app_context(): 72class User(db.Model): 73username = db.Column(db.String(32), unique=True, nullable=False, primary_key=True) 74password_hashed = db.Column(db.String(60), nullable=False) 75admin = db.Column(db.Boolean, nullable=False, default=False, server_default="false") 76pictures = db.relationship("PictureResource", back_populates="author") 77joined_timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 78galleries = db.relationship("Gallery", back_populates="owner") 79galleries_joined = db.relationship("UserInGallery", back_populates="user") 80 81def __init__(self, username, password): 82self.username = username 83self.password_hashed = bcrypt.generate_password_hash(password).decode("utf-8") 84 85@property 86def formatted_name(self): 87if self.admin: 88return self.username + "*" 89return self.username 90 91 92class Licence(db.Model): 93id = db.Column(db.String(64), primary_key=True) # SPDX identifier 94title = db.Column(db.UnicodeText, nullable=False) # the official name of the licence 95description = db.Column(db.UnicodeText, 96nullable=False) # brief description of its permissions and restrictions 97info_url = db.Column(db.String(1024), 98nullable=False) # the URL to a page with general information about the licence 99url = db.Column(db.String(1024), 100nullable=True) # the URL to a page with the full text of the licence and more information 101pictures = db.relationship("PictureLicence", back_populates="licence") 102free = db.Column(db.Boolean, nullable=False, 103default=False) # whether the licence is free or not 104logo_url = db.Column(db.String(1024), nullable=True) # URL to the logo of the licence 105pinned = db.Column(db.Boolean, nullable=False, 106default=False) # whether the licence should be shown at the top of the list 107 108def __init__(self, id, title, description, info_url, url, free, logo_url=None, 109pinned=False): 110self.id = id 111self.title = title 112self.description = description 113self.info_url = info_url 114self.url = url 115self.free = free 116self.logo_url = logo_url 117self.pinned = pinned 118 119 120class PictureLicence(db.Model): 121id = db.Column(db.Integer, primary_key=True, autoincrement=True) 122 123resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id")) 124licence_id = db.Column(db.String(64), db.ForeignKey("licence.id")) 125 126resource = db.relationship("PictureResource", back_populates="licences") 127licence = db.relationship("Licence", back_populates="pictures") 128 129def __init__(self, resource, licence): 130self.resource = resource 131self.licence = licence 132 133 134class Resource(db.Model): 135__abstract__ = True 136 137id = db.Column(db.Integer, primary_key=True, autoincrement=True) 138title = db.Column(db.UnicodeText, nullable=False) 139description = db.Column(db.UnicodeText, nullable=False) 140timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 141origin_url = db.Column(db.String(2048), 142nullable=True) # should be left empty if it's original or the source is unknown but public domain 143 144 145class PictureNature(db.Model): 146# Examples: 147# "photo", "paper-scan", "2d-art-photo", "sculpture-photo", "computer-3d", "computer-painting", 148# "computer-line-art", "diagram", "infographic", "text", "map", "chart-graph", "screen-capture", 149# "screen-photo", "pattern", "collage", "ai", and so on 150id = db.Column(db.String(64), primary_key=True) 151description = db.Column(db.UnicodeText, nullable=False) 152resources = db.relationship("PictureResource", back_populates="nature") 153 154def __init__(self, id, description): 155self.id = id 156self.description = description 157 158 159class PictureObjectInheritance(db.Model): 160parent_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 161primary_key=True) 162child_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 163primary_key=True) 164 165parent = db.relationship("PictureObject", foreign_keys=[parent_id], 166back_populates="child_links") 167child = db.relationship("PictureObject", foreign_keys=[child_id], 168back_populates="parent_links") 169 170def __init__(self, parent, child): 171self.parent = parent 172self.child = child 173 174 175class PictureObject(db.Model): 176id = db.Column(db.String(64), primary_key=True) 177description = db.Column(db.UnicodeText, nullable=False) 178 179child_links = db.relationship("PictureObjectInheritance", 180foreign_keys=[PictureObjectInheritance.parent_id], 181back_populates="parent") 182parent_links = db.relationship("PictureObjectInheritance", 183foreign_keys=[PictureObjectInheritance.child_id], 184back_populates="child") 185 186def __init__(self, id, description): 187self.id = id 188self.description = description 189 190 191class PictureRegion(db.Model): 192# This is for picture region annotations 193id = db.Column(db.Integer, primary_key=True, autoincrement=True) 194json = db.Column(sqlalchemy.dialects.postgresql.JSONB, nullable=False) 195 196resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 197nullable=False) 198object_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), nullable=True) 199 200resource = db.relationship("PictureResource", backref="regions") 201object = db.relationship("PictureObject", backref="regions") 202 203def __init__(self, json, resource, object): 204self.json = json 205self.resource = resource 206self.object = object 207 208 209class PictureResource(Resource): 210# This is only for bitmap pictures. Vectors will be stored under a different model 211# File name is the ID in the picture directory under data, without an extension 212file_format = db.Column(db.String(64), nullable=False) # MIME type 213width = db.Column(db.Integer, nullable=False) 214height = db.Column(db.Integer, nullable=False) 215nature_id = db.Column(db.String(32), db.ForeignKey("picture_nature.id"), nullable=True) 216author_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 217author = db.relationship("User", back_populates="pictures") 218 219nature = db.relationship("PictureNature", back_populates="resources") 220 221replaces_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=True) 222replaced_by_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 223nullable=True) 224 225replaces = db.relationship("PictureResource", remote_side="PictureResource.id", 226foreign_keys=[replaces_id], back_populates="replaced_by", 227post_update=True) 228replaced_by = db.relationship("PictureResource", remote_side="PictureResource.id", 229foreign_keys=[replaced_by_id], post_update=True) 230 231copied_from_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 232nullable=True) 233copied_from = db.relationship("PictureResource", remote_side="PictureResource.id", 234backref="copies", foreign_keys=[copied_from_id]) 235 236licences = db.relationship("PictureLicence", back_populates="resource") 237galleries = db.relationship("PictureInGallery", back_populates="resource") 238 239def __init__(self, title, author, description, origin_url, licence_ids, mime, 240nature=None): 241self.title = title 242self.author = author 243self.description = description 244self.origin_url = origin_url 245self.file_format = mime 246self.width = self.height = 0 247self.nature = nature 248db.session.add(self) 249db.session.commit() 250for licence_id in licence_ids: 251joiner = PictureLicence(self, db.session.get(Licence, licence_id)) 252db.session.add(joiner) 253 254def put_annotations(self, json): 255# Delete all previous annotations 256db.session.query(PictureRegion).filter_by(resource_id=self.id).delete() 257 258for region in json: 259object_id = region["object"] 260picture_object = db.session.get(PictureObject, object_id) 261 262region_data = { 263"type": region["type"], 264"shape": region["shape"], 265} 266 267region_row = PictureRegion(region_data, self, picture_object) 268db.session.add(region_row) 269 270 271class PictureInGallery(db.Model): 272id = db.Column(db.Integer, primary_key=True, autoincrement=True) 273resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 274nullable=False) 275gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) 276 277resource = db.relationship("PictureResource") 278gallery = db.relationship("Gallery") 279 280def __init__(self, resource, gallery): 281self.resource = resource 282self.gallery = gallery 283 284 285class UserInGallery(db.Model): 286id = db.Column(db.Integer, primary_key=True, autoincrement=True) 287username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 288gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) 289 290user = db.relationship("User") 291gallery = db.relationship("Gallery") 292 293def __init__(self, user, gallery): 294self.user = user 295self.gallery = gallery 296 297 298class Gallery(db.Model): 299id = db.Column(db.Integer, primary_key=True, autoincrement=True) 300title = db.Column(db.UnicodeText, nullable=False) 301description = db.Column(db.UnicodeText, nullable=False) 302pictures = db.relationship("PictureInGallery", back_populates="gallery") 303owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 304owner = db.relationship("User", back_populates="galleries") 305users = db.relationship("UserInGallery", back_populates="gallery") 306 307def __init__(self, title, description, owner): 308self.title = title 309self.description = description 310self.owner = owner 311 312 313@app.route("/") 314def index(): 315return flask.render_template("home.html", resources=PictureResource.query.order_by( 316db.func.random()).limit(10).all()) 317 318 319@app.route("/accounts/") 320def accounts(): 321return flask.render_template("login.html") 322 323 324@app.route("/login", methods=["POST"]) 325def login(): 326username = flask.request.form["username"] 327password = flask.request.form["password"] 328 329user = db.session.get(User, username) 330 331if user is None: 332flask.flash("This username is not registered.") 333return flask.redirect("/accounts") 334 335if not bcrypt.check_password_hash(user.password_hashed, password): 336flask.flash("Incorrect password.") 337return flask.redirect("/accounts") 338 339flask.flash("You have been logged in.") 340 341flask.session["username"] = username 342return flask.redirect("/") 343 344 345@app.route("/logout") 346def logout(): 347flask.session.pop("username", None) 348flask.flash("You have been logged out.") 349return flask.redirect("/") 350 351 352@app.route("/signup", methods=["POST"]) 353def signup(): 354username = flask.request.form["username"] 355password = flask.request.form["password"] 356 357if db.session.get(User, username) is not None: 358flask.flash("This username is already taken.") 359return flask.redirect("/accounts") 360 361if set(username) > set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"): 362flask.flash( 363"Usernames can only contain the Latin alphabet, digits, hyphens, and underscores.") 364return flask.redirect("/accounts") 365 366if len(username) < 3 or len(username) > 32: 367flask.flash("Usernames must be between 3 and 32 characters long.") 368return flask.redirect("/accounts") 369 370if len(password) < 6: 371flask.flash("Passwords must be at least 6 characters long.") 372return flask.redirect("/accounts") 373 374user = User(username, password) 375db.session.add(user) 376db.session.commit() 377 378flask.session["username"] = username 379 380flask.flash("You have been registered and logged in.") 381 382return flask.redirect("/") 383 384 385@app.route("/profile", defaults={"username": None}) 386@app.route("/profile/<username>") 387def profile(username): 388if username is None: 389if "username" in flask.session: 390return flask.redirect("/profile/" + flask.session["username"]) 391else: 392flask.flash("Please log in to perform this action.") 393return flask.redirect("/accounts") 394 395user = db.session.get(User, username) 396if user is None: 397flask.abort(404) 398 399return flask.render_template("profile.html", user=user) 400 401 402@app.route("/object/<id>") 403def has_object(id): 404object_ = db.session.get(PictureObject, id) 405if object_ is None: 406flask.abort(404) 407 408query = db.session.query(PictureResource).join(PictureRegion).filter( 409PictureRegion.object_id == id) 410 411page = int(flask.request.args.get("page", 1)) 412per_page = int(flask.request.args.get("per_page", 16)) 413 414resources = query.paginate(page=page, per_page=per_page) 415 416return flask.render_template("object.html", object=object_, resources=resources, 417page_number=page, 418page_length=per_page, num_pages=resources.pages, 419prev_page=resources.prev_num, 420next_page=resources.next_num, PictureRegion=PictureRegion) 421 422 423@app.route("/upload") 424def upload(): 425if "username" not in flask.session: 426flask.flash("Log in to upload pictures.") 427return flask.redirect("/accounts") 428 429licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), 430Licence.title).all() 431 432types = PictureNature.query.all() 433 434return flask.render_template("upload.html", licences=licences, types=types) 435 436 437@app.route("/upload", methods=["POST"]) 438def upload_post(): 439title = flask.request.form["title"] 440description = flask.request.form["description"] 441origin_url = flask.request.form["origin_url"] 442author = db.session.get(User, flask.session.get("username")) 443licence_ids = flask.request.form.getlist("licence") 444nature_id = flask.request.form["nature"] 445 446if author is None: 447flask.abort(401) 448 449file = flask.request.files["file"] 450 451if not file or not file.filename: 452flask.flash("Select a file") 453return flask.redirect(flask.request.url) 454 455if not file.mimetype.startswith("image/"): 456flask.flash("Only images are supported") 457return flask.redirect(flask.request.url) 458 459if not title: 460flask.flash("Enter a title") 461return flask.redirect(flask.request.url) 462 463if not description: 464description = "" 465 466if not nature_id: 467flask.flash("Select a picture type") 468return flask.redirect(flask.request.url) 469 470if not licence_ids: 471flask.flash("Select licences") 472return flask.redirect(flask.request.url) 473 474licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 475if not any(licence.free for licence in licences): 476flask.flash("Select at least one free licence") 477return flask.redirect(flask.request.url) 478 479resource = PictureResource(title, author, description, origin_url, licence_ids, 480file.mimetype, 481db.session.get(PictureNature, nature_id)) 482db.session.add(resource) 483db.session.commit() 484file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 485pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 486resource.width, resource.height = pil_image.size 487db.session.commit() 488 489if flask.request.form.get("annotations"): 490try: 491resource.put_annotations(json.loads(flask.request.form.get("annotations"))) 492db.session.commit() 493except json.JSONDecodeError: 494flask.flash("Invalid annotations") 495 496flask.flash("Picture uploaded successfully") 497 498return flask.redirect("/picture/" + str(resource.id)) 499 500 501@app.route("/picture/<int:id>/") 502def picture(id): 503resource = db.session.get(PictureResource, id) 504if resource is None: 505flask.abort(404) 506 507image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 508 509current_user = db.session.get(User, flask.session.get("username")) 510have_permission = current_user and (current_user == resource.author or current_user.admin) 511 512return flask.render_template("picture.html", resource=resource, 513file_extension=mimetypes.guess_extension(resource.file_format), 514size=image.size, copies=resource.copies, 515have_permission=have_permission) 516 517 518@app.route("/picture/<int:id>/annotate") 519def annotate_picture(id): 520resource = db.session.get(PictureResource, id) 521if resource is None: 522flask.abort(404) 523 524current_user = db.session.get(User, flask.session.get("username")) 525if current_user is None: 526flask.abort(401) 527if resource.author != current_user and not current_user.admin: 528flask.abort(403) 529 530return flask.render_template("picture-annotation.html", resource=resource, 531file_extension=mimetypes.guess_extension(resource.file_format)) 532 533 534@app.route("/picture/<int:id>/put-annotations-form") 535def put_annotations_form(id): 536resource = db.session.get(PictureResource, id) 537if resource is None: 538flask.abort(404) 539 540current_user = db.session.get(User, flask.session.get("username")) 541if current_user is None: 542flask.abort(401) 543 544if resource.author != current_user and not current_user.admin: 545flask.abort(403) 546 547return flask.render_template("put-annotations-form.html", resource=resource) 548 549 550@app.route("/picture/<int:id>/put-annotations-form", methods=["POST"]) 551def put_annotations_form_post(id): 552resource = db.session.get(PictureResource, id) 553if resource is None: 554flask.abort(404) 555 556current_user = db.session.get(User, flask.session.get("username")) 557if current_user is None: 558flask.abort(401) 559 560if resource.author != current_user and not current_user.admin: 561flask.abort(403) 562 563resource.put_annotations(json.loads(flask.request.form["annotations"])) 564 565db.session.commit() 566 567return flask.redirect("/picture/" + str(resource.id)) 568 569 570@app.route("/picture/<int:id>/save-annotations", methods=["POST"]) 571def save_annotations(id): 572resource = db.session.get(PictureResource, id) 573if resource is None: 574flask.abort(404) 575 576current_user = db.session.get(User, flask.session.get("username")) 577if resource.author != current_user and not current_user.admin: 578flask.abort(403) 579 580resource.put_annotations(flask.request.json) 581 582db.session.commit() 583 584response = flask.make_response() 585response.status_code = 204 586return response 587 588 589@app.route("/picture/<int:id>/get-annotations") 590def get_annotations(id): 591resource = db.session.get(PictureResource, id) 592if resource is None: 593flask.abort(404) 594 595regions = db.session.query(PictureRegion).filter_by(resource_id=id).all() 596 597regions_json = [] 598 599for region in regions: 600regions_json.append({ 601"object": region.object_id, 602"type": region.json["type"], 603"shape": region.json["shape"], 604}) 605 606return flask.jsonify(regions_json) 607 608 609@app.route("/picture/<int:id>/delete") 610def delete_picture(id): 611resource = db.session.get(PictureResource, id) 612if resource is None: 613flask.abort(404) 614 615current_user = db.session.get(User, flask.session.get("username")) 616if current_user is None: 617flask.abort(401) 618 619if resource.author != current_user and not current_user.admin: 620flask.abort(403) 621 622PictureLicence.query.filter_by(resource=resource).delete() 623PictureRegion.query.filter_by(resource=resource).delete() 624PictureInGallery.query.filter_by(resource=resource).delete() 625if resource.replaces: 626resource.replaces.replaced_by = None 627if resource.replaced_by: 628resource.replaced_by.replaces = None 629resource.copied_from = None 630for copy in resource.copies: 631copy.copied_from = None 632db.session.delete(resource) 633db.session.commit() 634 635return flask.redirect("/") 636 637 638@app.route("/picture/<int:id>/mark-replacement", methods=["POST"]) 639def mark_picture_replacement(id): 640resource = db.session.get(PictureResource, id) 641if resource is None: 642flask.abort(404) 643 644current_user = db.session.get(User, flask.session.get("username")) 645if current_user is None: 646flask.abort(401) 647 648if resource.copied_from.author != current_user and not current_user.admin: 649flask.abort(403) 650 651resource.copied_from.replaced_by = resource 652resource.replaces = resource.copied_from 653 654db.session.commit() 655 656return flask.redirect("/picture/" + str(resource.copied_from.id)) 657 658 659@app.route("/picture/<int:id>/remove-replacement", methods=["POST"]) 660def remove_picture_replacement(id): 661resource = db.session.get(PictureResource, id) 662if resource is None: 663flask.abort(404) 664 665current_user = db.session.get(User, flask.session.get("username")) 666if current_user is None: 667flask.abort(401) 668 669if resource.author != current_user and not current_user.admin: 670flask.abort(403) 671 672resource.replaced_by.replaces = None 673resource.replaced_by = None 674 675db.session.commit() 676 677return flask.redirect("/picture/" + str(resource.id)) 678 679 680@app.route("/picture/<int:id>/edit-metadata") 681def edit_picture(id): 682resource = db.session.get(PictureResource, id) 683if resource is None: 684flask.abort(404) 685 686current_user = db.session.get(User, flask.session.get("username")) 687if current_user is None: 688flask.abort(401) 689 690if resource.author != current_user and not current_user.admin: 691flask.abort(403) 692 693licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), 694Licence.title).all() 695 696types = PictureNature.query.all() 697 698return flask.render_template("edit-picture.html", resource=resource, licences=licences, 699types=types, 700PictureLicence=PictureLicence) 701 702 703@app.route("/picture/<int:id>/edit-metadata", methods=["POST"]) 704def edit_picture_post(id): 705resource = db.session.get(PictureResource, id) 706if resource is None: 707flask.abort(404) 708 709current_user = db.session.get(User, flask.session.get("username")) 710if current_user is None: 711flask.abort(401) 712 713if resource.author != current_user and not current_user.admin: 714flask.abort(403) 715 716title = flask.request.form["title"] 717description = flask.request.form["description"] 718origin_url = flask.request.form["origin_url"] 719licence_ids = flask.request.form.getlist("licence") 720nature_id = flask.request.form["nature"] 721 722if not title: 723flask.flash("Enter a title") 724return flask.redirect(flask.request.url) 725 726if not description: 727description = "" 728 729if not nature_id: 730flask.flash("Select a picture type") 731return flask.redirect(flask.request.url) 732 733if not licence_ids: 734flask.flash("Select licences") 735return flask.redirect(flask.request.url) 736 737licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 738if not any(licence.free for licence in licences): 739flask.flash("Select at least one free licence") 740return flask.redirect(flask.request.url) 741 742resource.title = title 743resource.description = description 744resource.origin_url = origin_url 745for licence_id in licence_ids: 746joiner = PictureLicence(resource, db.session.get(Licence, licence_id)) 747db.session.add(joiner) 748resource.nature = db.session.get(PictureNature, nature_id) 749 750db.session.commit() 751 752return flask.redirect("/picture/" + str(resource.id)) 753 754 755@app.route("/picture/<int:id>/copy") 756def copy_picture(id): 757resource = db.session.get(PictureResource, id) 758if resource is None: 759flask.abort(404) 760 761current_user = db.session.get(User, flask.session.get("username")) 762if current_user is None: 763flask.abort(401) 764 765new_resource = PictureResource(resource.title, current_user, resource.description, 766resource.origin_url, 767[licence.licence_id for licence in resource.licences], 768resource.file_format, 769resource.nature) 770 771for region in resource.regions: 772db.session.add(PictureRegion(region.json, new_resource, region.object)) 773 774db.session.commit() 775 776# Create a hard link for the new picture 777old_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 778new_path = path.join(config.DATA_PATH, "pictures", str(new_resource.id)) 779os.link(old_path, new_path) 780 781new_resource.width = resource.width 782new_resource.height = resource.height 783new_resource.copied_from = resource 784 785db.session.commit() 786 787return flask.redirect("/picture/" + str(new_resource.id)) 788 789 790@app.route("/gallery/<int:id>/") 791def gallery(id): 792gallery = db.session.get(Gallery, id) 793if gallery is None: 794flask.abort(404) 795 796current_user = db.session.get(User, flask.session.get("username")) 797 798have_permission = current_user and (current_user == gallery.owner or current_user.admin or UserInGallery.query.filter_by(user=current_user, gallery=gallery).first()) 799 800return flask.render_template("gallery.html", gallery=gallery, 801have_permission=have_permission) 802 803 804@app.route("/create-gallery") 805def create_gallery(): 806if "username" not in flask.session: 807flask.flash("Log in to create galleries.") 808return flask.redirect("/accounts") 809 810return flask.render_template("create-gallery.html") 811 812 813@app.route("/create-gallery", methods=["POST"]) 814def create_gallery_post(): 815if not flask.session.get("username"): 816flask.abort(401) 817 818if not flask.request.form.get("title"): 819flask.flash("Enter a title") 820return flask.redirect(flask.request.url) 821 822description = flask.request.form.get("description", "") 823 824gallery = Gallery(flask.request.form["title"], description, 825db.session.get(User, flask.session["username"])) 826db.session.add(gallery) 827db.session.commit() 828 829return flask.redirect("/gallery/" + str(gallery.id)) 830 831 832@app.route("/gallery/<int:id>/add-picture", methods=["POST"]) 833def gallery_add_picture(id): 834gallery = db.session.get(Gallery, id) 835if gallery is None: 836flask.abort(404) 837 838if "username" not in flask.session: 839flask.abort(401) 840 841if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 842flask.abort(403) 843 844picture_id = flask.request.form.get("picture_id") 845if "/" in picture_id: # also allow full URLs 846picture_id = picture_id.rstrip("/").rpartition("/")[1] 847if not picture_id: 848flask.flash("Select a picture") 849return flask.redirect("/gallery/" + str(gallery.id)) 850picture_id = int(picture_id) 851 852picture = db.session.get(PictureResource, picture_id) 853if picture is None: 854flask.flash("Invalid picture") 855return flask.redirect("/gallery/" + str(gallery.id)) 856 857if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 858flask.flash("This picture is already in the gallery") 859return flask.redirect("/gallery/" + str(gallery.id)) 860 861db.session.add(PictureInGallery(picture, gallery)) 862 863db.session.commit() 864 865return flask.redirect("/gallery/" + str(gallery.id)) 866 867 868@app.route("/gallery/<int:id>/remove-picture", methods=["POST"]) 869def gallery_remove_picture(id): 870gallery = db.session.get(Gallery, id) 871if gallery is None: 872flask.abort(404) 873 874if "username" not in flask.session: 875flask.abort(401) 876 877current_user = db.session.get(User, flask.session.get("username")) 878 879if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 880flask.abort(403) 881 882picture_id = int(flask.request.form.get("picture_id")) 883 884picture = db.session.get(PictureResource, picture_id) 885if picture is None: 886flask.flash("Invalid picture") 887return flask.redirect("/gallery/" + str(gallery.id)) 888 889picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, 890gallery=gallery).first() 891if picture_in_gallery is None: 892flask.flash("This picture isn't in the gallery") 893return flask.redirect("/gallery/" + str(gallery.id)) 894 895db.session.delete(picture_in_gallery) 896 897db.session.commit() 898 899return flask.redirect("/gallery/" + str(gallery.id)) 900 901 902@app.route("/gallery/<int:id>/add-pictures-from-query", methods=["POST"]) 903def gallery_add_from_query(id): 904gallery = db.session.get(Gallery, id) 905if gallery is None: 906flask.abort(404) 907 908if "username" not in flask.session: 909flask.abort(401) 910 911if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 912flask.abort(403) 913 914query_yaml = flask.request.form.get("query", "") 915 916yaml_parser = yaml.YAML() 917query_data = yaml_parser.load(query_yaml) or {} 918query = get_picture_query(query_data) 919 920pictures = query.all() 921 922count = 0 923 924for picture in pictures: 925if not PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 926db.session.add(PictureInGallery(picture, gallery)) 927count += 1 928 929db.session.commit() 930 931flask.flash(f"Added {count} pictures to the gallery") 932 933return flask.redirect("/gallery/" + str(gallery.id)) 934 935 936@app.route("/gallery/<int:id>/users") 937def gallery_users(id): 938gallery = db.session.get(Gallery, id) 939if gallery is None: 940flask.abort(404) 941 942current_user = db.session.get(User, flask.session.get("username")) 943have_permission = current_user and (current_user == gallery.owner or current_user.admin) 944 945return flask.render_template("gallery-users.html", gallery=gallery, 946have_permission=have_permission) 947 948 949@app.route("/gallery/<int:id>/users/add", methods=["POST"]) 950def gallery_add_user(id): 951gallery = db.session.get(Gallery, id) 952if gallery is None: 953flask.abort(404) 954 955current_user = db.session.get(User, flask.session.get("username")) 956if current_user is None: 957flask.abort(401) 958 959if current_user != gallery.owner and not current_user.admin: 960flask.abort(403) 961 962username = flask.request.form.get("username") 963if username == gallery.owner_name: 964flask.flash("The owner is already in the gallery") 965return flask.redirect("/gallery/" + str(gallery.id) + "/users") 966 967user = db.session.get(User, username) 968if user is None: 969flask.flash("User not found") 970return flask.redirect("/gallery/" + str(gallery.id) + "/users") 971 972if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 973flask.flash("User is already in the gallery") 974return flask.redirect("/gallery/" + str(gallery.id) + "/users") 975 976db.session.add(UserInGallery(user, gallery)) 977 978db.session.commit() 979 980return flask.redirect("/gallery/" + str(gallery.id) + "/users") 981 982 983@app.route("/gallery/<int:id>/users/remove", methods=["POST"]) 984def gallery_remove_user(id): 985gallery = db.session.get(Gallery, id) 986if gallery is None: 987flask.abort(404) 988 989current_user = db.session.get(User, flask.session.get("username")) 990if current_user is None: 991flask.abort(401) 992 993if current_user != gallery.owner and not current_user.admin: 994flask.abort(403) 995 996username = flask.request.form.get("username") 997user = db.session.get(User, username) 998if user is None: 999flask.flash("User not found") 1000return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1001 1002user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 1003if user_in_gallery is None: 1004flask.flash("User is not in the gallery") 1005return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1006 1007db.session.delete(user_in_gallery) 1008 1009db.session.commit() 1010 1011return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1012 1013 1014class APIError(Exception): 1015def __init__(self, status_code, message): 1016self.status_code = status_code 1017self.message = message 1018 1019 1020def get_picture_query(query_data): 1021query = db.session.query(PictureResource) 1022 1023requirement_conditions = { 1024"has_object": lambda value: PictureResource.regions.any( 1025PictureRegion.object_id.in_(value)), 1026"nature": lambda value: PictureResource.nature_id.in_(value), 1027"licence": lambda value: PictureResource.licences.any( 1028PictureLicence.licence_id.in_(value)), 1029"author": lambda value: PictureResource.author_name.in_(value), 1030"title": lambda value: PictureResource.title.ilike(value), 1031"description": lambda value: PictureResource.description.ilike(value), 1032"origin_url": lambda value: db.func.lower(db.func.substr( 1033PictureResource.origin_url, 1034db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 1035)).in_(value), 1036"above_width": lambda value: PictureResource.width >= value, 1037"below_width": lambda value: PictureResource.width <= value, 1038"above_height": lambda value: PictureResource.height >= value, 1039"below_height": lambda value: PictureResource.height <= value, 1040"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 1041value), 1042"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 1043value), 1044"in_gallery": lambda value: PictureResource.galleries.any(PictureInGallery.gallery_id.in_(value)), 1045} 1046if "want" in query_data: 1047for i in query_data["want"]: 1048if len(i) != 1: 1049raise APIError(400, "Each requirement must have exactly one key") 1050requirement, value = list(i.items())[0] 1051if requirement not in requirement_conditions: 1052raise APIError(400, f"Unknown requirement type: {requirement}") 1053 1054condition = requirement_conditions[requirement] 1055query = query.filter(condition(value)) 1056if "exclude" in query_data: 1057for i in query_data["exclude"]: 1058if len(i) != 1: 1059raise APIError(400, "Each exclusion must have exactly one key") 1060requirement, value = list(i.items())[0] 1061if requirement not in requirement_conditions: 1062raise APIError(400, f"Unknown requirement type: {requirement}") 1063 1064condition = requirement_conditions[requirement] 1065query = query.filter(~condition(value)) 1066if not query_data.get("include_obsolete", False): 1067query = query.filter(PictureResource.replaced_by_id.is_(None)) 1068 1069return query 1070 1071 1072@app.route("/query-pictures") 1073def graphical_query_pictures(): 1074return flask.render_template("graphical-query-pictures.html") 1075 1076 1077@app.route("/query-pictures-results") 1078def graphical_query_pictures_results(): 1079query_yaml = flask.request.args.get("query", "") 1080yaml_parser = yaml.YAML() 1081query_data = yaml_parser.load(query_yaml) or {} 1082try: 1083query = get_picture_query(query_data) 1084except APIError as e: 1085flask.abort(e.status_code) 1086 1087page = int(flask.request.args.get("page", 1)) 1088per_page = int(flask.request.args.get("per_page", 16)) 1089 1090resources = query.paginate(page=page, per_page=per_page) 1091 1092return flask.render_template("graphical-query-pictures-results.html", resources=resources, 1093query=query_yaml, 1094page_number=page, page_length=per_page, 1095num_pages=resources.pages, 1096prev_page=resources.prev_num, next_page=resources.next_num) 1097 1098 1099@app.route("/raw/picture/<int:id>") 1100def raw_picture(id): 1101resource = db.session.get(PictureResource, id) 1102if resource is None: 1103flask.abort(404) 1104 1105response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), 1106str(resource.id)) 1107response.mimetype = resource.file_format 1108 1109return response 1110 1111 1112@app.route("/object/") 1113def graphical_object_types(): 1114return flask.render_template("object-types.html", objects=PictureObject.query.all()) 1115 1116 1117@app.route("/api/object-types") 1118def object_types(): 1119objects = db.session.query(PictureObject).all() 1120return flask.jsonify({object.id: object.description for object in objects}) 1121 1122 1123@app.route("/api/query-pictures", methods=["POST"]) # sadly GET can't have a body 1124def query_pictures(): 1125offset = int(flask.request.args.get("offset", 0)) 1126limit = int(flask.request.args.get("limit", 16)) 1127ordering = flask.request.args.get("ordering", "date-desc") 1128 1129yaml_parser = yaml.YAML() 1130query_data = yaml_parser.load(flask.request.data) or {} 1131try: 1132query = get_picture_query(query_data) 1133except APIError as e: 1134return flask.jsonify({"error": e.message}), e.status_code 1135 1136match ordering: 1137case "date-desc": 1138query = query.order_by(PictureResource.timestamp.desc()) 1139case "date-asc": 1140query = query.order_by(PictureResource.timestamp.asc()) 1141case "title-asc": 1142query = query.order_by(PictureResource.title.asc()) 1143case "title-desc": 1144query = query.order_by(PictureResource.title.desc()) 1145case "random": 1146query = query.order_by(db.func.random()) 1147case "number-regions-desc": 1148query = query.order_by(db.func.count(PictureResource.regions).desc()) 1149case "number-regions-asc": 1150query = query.order_by(db.func.count(PictureResource.regions).asc()) 1151 1152query = query.offset(offset).limit(limit) 1153resources = query.all() 1154 1155json_response = { 1156"date_generated": datetime.utcnow().timestamp(), 1157"resources": [], 1158"offset": offset, 1159"limit": limit, 1160} 1161 1162json_resources = json_response["resources"] 1163 1164for resource in resources: 1165json_resource = { 1166"id": resource.id, 1167"title": resource.title, 1168"description": resource.description, 1169"timestamp": resource.timestamp.timestamp(), 1170"origin_url": resource.origin_url, 1171"author": resource.author_name, 1172"file_format": resource.file_format, 1173"width": resource.width, 1174"height": resource.height, 1175"nature": resource.nature_id, 1176"licences": [licence.licence_id for licence in resource.licences], 1177"replaces": resource.replaces_id, 1178"replaced_by": resource.replaced_by_id, 1179"regions": [], 1180"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1181} 1182for region in resource.regions: 1183json_resource["regions"].append({ 1184"object": region.object_id, 1185"type": region.json["type"], 1186"shape": region.json["shape"], 1187}) 1188 1189json_resources.append(json_resource) 1190 1191return flask.jsonify(json_response) 1192 1193 1194@app.route("/api/picture/<int:id>/") 1195def api_picture(id): 1196resource = db.session.get(PictureResource, id) 1197if resource is None: 1198flask.abort(404) 1199 1200json_resource = { 1201"id": resource.id, 1202"title": resource.title, 1203"description": resource.description, 1204"timestamp": resource.timestamp.timestamp(), 1205"origin_url": resource.origin_url, 1206"author": resource.author_name, 1207"file_format": resource.file_format, 1208"width": resource.width, 1209"height": resource.height, 1210"nature": resource.nature_id, 1211"licences": [licence.licence_id for licence in resource.licences], 1212"replaces": resource.replaces_id, 1213"replaced_by": resource.replaced_by_id, 1214"regions": [], 1215"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1216} 1217for region in resource.regions: 1218json_resource["regions"].append({ 1219"object": region.object_id, 1220"type": region.json["type"], 1221"shape": region.json["shape"], 1222}) 1223 1224return flask.jsonify(json_resource) 1225 1226 1227@app.route("/api/licence/") 1228def api_licences(): 1229licences = db.session.query(Licence).all() 1230json_licences = { 1231licence.id: { 1232"title": licence.title, 1233"free": licence.free, 1234"pinned": licence.pinned, 1235} for licence in licences 1236} 1237 1238return flask.jsonify(json_licences) 1239 1240 1241@app.route("/api/licence/<id>/") 1242def api_licence(id): 1243licence = db.session.get(Licence, id) 1244if licence is None: 1245flask.abort(404) 1246 1247json_licence = { 1248"id": licence.id, 1249"title": licence.title, 1250"description": licence.description, 1251"info_url": licence.info_url, 1252"legalese_url": licence.url, 1253"free": licence.free, 1254"logo_url": licence.logo_url, 1255"pinned": licence.pinned, 1256} 1257 1258return flask.jsonify(json_licence) 1259 1260 1261@app.route("/api/nature/") 1262def api_natures(): 1263natures = db.session.query(PictureNature).all() 1264json_natures = { 1265nature.id: nature.description for nature in natures 1266} 1267 1268return flask.jsonify(json_natures) 1269 1270 1271@app.route("/api/user/") 1272def api_users(): 1273offset = int(flask.request.args.get("offset", 0)) 1274limit = int(flask.request.args.get("limit", 16)) 1275 1276users = db.session.query(User).offset(offset).limit(limit).all() 1277 1278json_users = { 1279user.username: { 1280"admin": user.admin, 1281} for user in users 1282} 1283 1284return flask.jsonify(json_users) 1285 1286 1287@app.route("/api/user/<username>/") 1288def api_user(username): 1289user = db.session.get(User, username) 1290if user is None: 1291flask.abort(404) 1292 1293json_user = { 1294"username": user.username, 1295"admin": user.admin, 1296"joined": user.joined_timestamp.timestamp(), 1297} 1298 1299return flask.jsonify(json_user) 1300