app.py
Python script, ASCII text executable
1import json 2import os 3import mimetypes 4import flask 5import ruamel.yaml as yaml 6import sqlalchemy.dialects.postgresql 7import config 8import markdown 9 10from datetime import datetime 11from os import path 12from flask_sqlalchemy import SQLAlchemy 13from flask_bcrypt import Bcrypt 14from flask_migrate import Migrate, current 15from urllib.parse import urlencode 16from PIL import Image 17 18app = flask.Flask(__name__) 19bcrypt = Bcrypt(app) 20 21app.config["SQLALCHEMY_DATABASE_URI"] = config.DB_URI 22app.config["SECRET_KEY"] = config.DB_PASSWORD 23 24db = SQLAlchemy(app) 25migrate = Migrate(app, db) 26 27 28@app.template_filter("split") 29def split(value, separator=None, maxsplit=-1): 30return value.split(separator, maxsplit) 31 32 33@app.template_filter("median") 34def median(value): 35value = list(value) # prevent generators 36return sorted(value)[len(value) // 2] 37 38 39@app.template_filter("set") 40def set_filter(value): 41return set(value) 42 43 44@app.template_global() 45def modify_query(**new_values): 46args = flask.request.args.copy() 47for key, value in new_values.items(): 48args[key] = value 49 50return f"{flask.request.path}?{urlencode(args)}" 51 52 53@app.context_processor 54def default_variables(): 55return { 56"current_user": db.session.get(User, flask.session.get("username")), 57"site_name": config.SITE_NAME, 58} 59 60 61with app.app_context(): 62class User(db.Model): 63username = db.Column(db.String(32), unique=True, nullable=False, primary_key=True) 64password_hashed = db.Column(db.String(60), nullable=False) 65admin = db.Column(db.Boolean, nullable=False, default=False, server_default="false") 66pictures = db.relationship("PictureResource", back_populates="author") 67joined_timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 68galleries = db.relationship("Gallery", back_populates="owner") 69galleries_joined = db.relationship("UserInGallery", back_populates="user") 70ratings = db.relationship("PictureRating", back_populates="user") 71 72def __init__(self, username, password): 73self.username = username 74self.password_hashed = bcrypt.generate_password_hash(password).decode("utf-8") 75 76@property 77def formatted_name(self): 78if self.admin: 79return self.username + "*" 80return self.username 81 82 83class Licence(db.Model): 84id = db.Column(db.String(64), primary_key=True) # SPDX identifier 85title = db.Column(db.UnicodeText, nullable=False) # the official name of the licence 86description = db.Column(db.UnicodeText, 87nullable=False) # brief description of its permissions and restrictions 88info_url = db.Column(db.String(1024), 89nullable=False) # the URL to a page with general information about the licence 90url = db.Column(db.String(1024), 91nullable=True) # the URL to a page with the full text of the licence and more information 92pictures = db.relationship("PictureLicence", back_populates="licence") 93free = db.Column(db.Boolean, nullable=False, 94default=False) # whether the licence is free or not 95logo_url = db.Column(db.String(1024), nullable=True) # URL to the logo of the licence 96pinned = db.Column(db.Boolean, nullable=False, 97default=False) # whether the licence should be shown at the top of the list 98 99def __init__(self, id, title, description, info_url, url, free, logo_url=None, 100pinned=False): 101self.id = id 102self.title = title 103self.description = description 104self.info_url = info_url 105self.url = url 106self.free = free 107self.logo_url = logo_url 108self.pinned = pinned 109 110 111class PictureLicence(db.Model): 112id = db.Column(db.Integer, primary_key=True, autoincrement=True) 113 114resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id")) 115licence_id = db.Column(db.String(64), db.ForeignKey("licence.id")) 116 117resource = db.relationship("PictureResource", back_populates="licences") 118licence = db.relationship("Licence", back_populates="pictures") 119 120def __init__(self, resource, licence): 121self.resource = resource 122self.licence = licence 123 124 125class Resource(db.Model): 126__abstract__ = True 127 128id = db.Column(db.Integer, primary_key=True, autoincrement=True) 129title = db.Column(db.UnicodeText, nullable=False) 130description = db.Column(db.UnicodeText, nullable=False) 131timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) 132origin_url = db.Column(db.String(2048), 133nullable=True) # should be left empty if it's original or the source is unknown but public domain 134 135 136class PictureNature(db.Model): 137# Examples: 138# "photo", "paper-scan", "2d-art-photo", "sculpture-photo", "computer-3d", "computer-painting", 139# "computer-line-art", "diagram", "infographic", "text", "map", "chart-graph", "screen-capture", 140# "screen-photo", "pattern", "collage", "ai", and so on 141id = db.Column(db.String(64), primary_key=True) 142description = db.Column(db.UnicodeText, nullable=False) 143resources = db.relationship("PictureResource", back_populates="nature") 144 145def __init__(self, id, description): 146self.id = id 147self.description = description 148 149 150class PictureObjectInheritance(db.Model): 151parent_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 152primary_key=True) 153child_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), 154primary_key=True) 155 156parent = db.relationship("PictureObject", foreign_keys=[parent_id], 157back_populates="child_links") 158child = db.relationship("PictureObject", foreign_keys=[child_id], 159back_populates="parent_links") 160 161def __init__(self, parent, child): 162self.parent = parent 163self.child = child 164 165 166class PictureObject(db.Model): 167id = db.Column(db.String(64), primary_key=True) 168description = db.Column(db.UnicodeText, nullable=False) 169 170child_links = db.relationship("PictureObjectInheritance", 171foreign_keys=[PictureObjectInheritance.parent_id], 172back_populates="parent") 173parent_links = db.relationship("PictureObjectInheritance", 174foreign_keys=[PictureObjectInheritance.child_id], 175back_populates="child") 176 177def __init__(self, id, description, parents): 178self.id = id 179self.description = description 180if parents: 181for parent in parents: 182db.session.add(PictureObjectInheritance(parent, self)) 183 184 185class PictureRegion(db.Model): 186# This is for picture region annotations 187id = db.Column(db.Integer, primary_key=True, autoincrement=True) 188json = db.Column(sqlalchemy.dialects.postgresql.JSONB, nullable=False) 189 190resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 191nullable=False) 192object_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), nullable=True) 193 194resource = db.relationship("PictureResource", backref="regions") 195object = db.relationship("PictureObject", backref="regions") 196 197def __init__(self, json, resource, object): 198self.json = json 199self.resource = resource 200self.object = object 201 202 203class PictureResource(Resource): 204# This is only for bitmap pictures. Vectors will be stored under a different model 205# File name is the ID in the picture directory under data, without an extension 206file_format = db.Column(db.String(64), nullable=False) # MIME type 207width = db.Column(db.Integer, nullable=False) 208height = db.Column(db.Integer, nullable=False) 209nature_id = db.Column(db.String(32), db.ForeignKey("picture_nature.id"), nullable=True) 210author_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 211author = db.relationship("User", back_populates="pictures") 212 213nature = db.relationship("PictureNature", back_populates="resources") 214 215replaces_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=True) 216replaced_by_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 217nullable=True) 218 219replaces = db.relationship("PictureResource", remote_side="PictureResource.id", 220foreign_keys=[replaces_id], back_populates="replaced_by", 221post_update=True) 222replaced_by = db.relationship("PictureResource", remote_side="PictureResource.id", 223foreign_keys=[replaced_by_id], post_update=True) 224 225copied_from_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 226nullable=True) 227copied_from = db.relationship("PictureResource", remote_side="PictureResource.id", 228backref="copies", foreign_keys=[copied_from_id]) 229 230licences = db.relationship("PictureLicence", back_populates="resource") 231galleries = db.relationship("PictureInGallery", back_populates="resource") 232ratings = db.relationship("PictureRating", back_populates="resource") 233 234def __init__(self, title, author, description, origin_url, licence_ids, mime, 235nature=None): 236self.title = title 237self.author = author 238self.description = description 239self.origin_url = origin_url 240self.file_format = mime 241self.width = self.height = 0 242self.nature = nature 243db.session.add(self) 244db.session.commit() 245for licence_id in licence_ids: 246joiner = PictureLicence(self, db.session.get(Licence, licence_id)) 247db.session.add(joiner) 248 249def put_annotations(self, json): 250# Delete all previous annotations 251db.session.query(PictureRegion).filter_by(resource_id=self.id).delete() 252 253for region in json: 254object_id = region["object"] 255picture_object = db.session.get(PictureObject, object_id) 256 257region_data = { 258"type": region["type"], 259"shape": region["shape"], 260} 261 262region_row = PictureRegion(region_data, self, picture_object) 263db.session.add(region_row) 264 265@property 266def average_rating(self): 267if not self.ratings: 268return None 269return db.session.query(db.func.avg(PictureRating.rating)).filter_by(resource=self).scalar() 270 271@property 272def rating_totals(self): 273all_ratings = db.session.query(PictureRating.rating).filter_by(resource=self) 274return {rating: all_ratings.filter_by(rating=rating).count() for rating in range(1, 6)} 275 276@property 277def stars(self): 278if not self.ratings: 279return 0 280average = self.average_rating 281whole_stars = int(average) 282partial_star = average - whole_stars 283 284return [100] * whole_stars + [int(partial_star * 100)] + [0] * (4 - whole_stars) 285 286 287class PictureInGallery(db.Model): 288id = db.Column(db.Integer, primary_key=True, autoincrement=True) 289resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), 290nullable=False) 291gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) 292 293resource = db.relationship("PictureResource") 294gallery = db.relationship("Gallery") 295 296def __init__(self, resource, gallery): 297self.resource = resource 298self.gallery = gallery 299 300 301class UserInGallery(db.Model): 302id = db.Column(db.Integer, primary_key=True, autoincrement=True) 303username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 304gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) 305 306user = db.relationship("User") 307gallery = db.relationship("Gallery") 308 309def __init__(self, user, gallery): 310self.user = user 311self.gallery = gallery 312 313 314class Gallery(db.Model): 315id = db.Column(db.Integer, primary_key=True, autoincrement=True) 316title = db.Column(db.UnicodeText, nullable=False) 317description = db.Column(db.UnicodeText, nullable=False) 318pictures = db.relationship("PictureInGallery", back_populates="gallery") 319owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 320owner = db.relationship("User", back_populates="galleries") 321users = db.relationship("UserInGallery", back_populates="gallery") 322 323def __init__(self, title, description, owner): 324self.title = title 325self.description = description 326self.owner = owner 327 328 329class PictureRating(db.Model): 330id = db.Column(db.Integer, primary_key=True, autoincrement=True) 331resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=False) 332username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) 333rating = db.Column(db.Integer, db.CheckConstraint("rating >= 1 AND rating <= 5"), 334nullable=False) 335 336resource = db.relationship("PictureResource", back_populates="ratings") 337user = db.relationship("User", back_populates="ratings") 338 339def __init__(self, resource, user, rating): 340self.resource = resource 341self.user = user 342self.rating = rating 343 344 345@app.route("/") 346def index(): 347return flask.render_template("home.html", resources=PictureResource.query.filter_by(replaced_by=None).order_by( 348db.func.random()).limit(10).all()) 349 350 351@app.route("/info/") 352def usage_guide(): 353with open("help/usage.md") as f: 354return flask.render_template("help.html", content=markdown.markdown2html(f.read())) 355 356 357@app.route("/accounts/") 358def accounts(): 359return flask.render_template("login.html") 360 361 362@app.route("/login", methods=["POST"]) 363def login(): 364username = flask.request.form["username"] 365password = flask.request.form["password"] 366 367user = db.session.get(User, username) 368 369if user is None: 370flask.flash("This username is not registered.") 371return flask.redirect("/accounts") 372 373if not bcrypt.check_password_hash(user.password_hashed, password): 374flask.flash("Incorrect password.") 375return flask.redirect("/accounts") 376 377flask.flash("You have been logged in.") 378 379flask.session["username"] = username 380return flask.redirect("/") 381 382 383@app.route("/logout") 384def logout(): 385flask.session.pop("username", None) 386flask.flash("You have been logged out.") 387return flask.redirect("/") 388 389 390@app.route("/signup", methods=["POST"]) 391def signup(): 392username = flask.request.form["username"] 393password = flask.request.form["password"] 394 395if db.session.get(User, username) is not None: 396flask.flash("This username is already taken.") 397return flask.redirect("/accounts") 398 399if set(username) > set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"): 400flask.flash( 401"Usernames can only contain the Latin alphabet, digits, hyphens, and underscores.") 402return flask.redirect("/accounts") 403 404if len(username) < 3 or len(username) > 32: 405flask.flash("Usernames must be between 3 and 32 characters long.") 406return flask.redirect("/accounts") 407 408if len(password) < 6: 409flask.flash("Passwords must be at least 6 characters long.") 410return flask.redirect("/accounts") 411 412user = User(username, password) 413db.session.add(user) 414db.session.commit() 415 416flask.session["username"] = username 417 418flask.flash("You have been registered and logged in.") 419 420return flask.redirect("/") 421 422 423@app.route("/profile", defaults={"username": None}) 424@app.route("/profile/<username>") 425def profile(username): 426if username is None: 427if "username" in flask.session: 428return flask.redirect("/profile/" + flask.session["username"]) 429else: 430flask.flash("Please log in to perform this action.") 431return flask.redirect("/accounts") 432 433user = db.session.get(User, username) 434if user is None: 435flask.abort(404) 436 437return flask.render_template("profile.html", user=user) 438 439 440@app.route("/object/<id>") 441def has_object(id): 442object_ = db.session.get(PictureObject, id) 443if object_ is None: 444flask.abort(404) 445 446descendants_cte = ( 447db.select(PictureObject.id) 448.where(PictureObject.id == id) 449.cte(name="descendants_cte", recursive=True) 450) 451 452descendants_cte = descendants_cte.union_all( 453db.select(PictureObjectInheritance.child_id) 454.where(PictureObjectInheritance.parent_id == descendants_cte.c.id) 455) 456 457query = db.session.query(PictureResource).filter( 458PictureResource.regions.any( 459PictureRegion.object_id.in_( 460db.select(descendants_cte.c.id) 461) 462) 463) 464 465page = int(flask.request.args.get("page", 1)) 466per_page = int(flask.request.args.get("per_page", 16)) 467 468resources = query.paginate(page=page, per_page=per_page) 469 470return flask.render_template("object.html", object=object_, resources=resources, 471page_number=page, 472page_length=per_page, num_pages=resources.pages, 473prev_page=resources.prev_num, 474next_page=resources.next_num, PictureRegion=PictureRegion) 475 476 477@app.route("/upload") 478def upload(): 479if "username" not in flask.session: 480flask.flash("Log in to upload pictures.") 481return flask.redirect("/accounts") 482 483licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), 484Licence.title).all() 485 486types = PictureNature.query.all() 487 488return flask.render_template("upload.html", licences=licences, types=types) 489 490 491@app.route("/upload", methods=["POST"]) 492def upload_post(): 493title = flask.request.form["title"] 494description = flask.request.form["description"] 495origin_url = flask.request.form["origin_url"] 496author = db.session.get(User, flask.session.get("username")) 497licence_ids = flask.request.form.getlist("licence") 498nature_id = flask.request.form["nature"] 499 500if author is None: 501flask.abort(401) 502 503file = flask.request.files["file"] 504 505if not file or not file.filename: 506flask.flash("Select a file") 507return flask.redirect(flask.request.url) 508 509if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 510flask.flash("Only images are supported") 511return flask.redirect(flask.request.url) 512 513if not title: 514flask.flash("Enter a title") 515return flask.redirect(flask.request.url) 516 517if not description: 518description = "" 519 520if not nature_id: 521flask.flash("Select a picture type") 522return flask.redirect(flask.request.url) 523 524if not licence_ids: 525flask.flash("Select licences") 526return flask.redirect(flask.request.url) 527 528licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 529if not any(licence.free for licence in licences): 530flask.flash("Select at least one free licence") 531return flask.redirect(flask.request.url) 532 533resource = PictureResource(title, author, description, origin_url, licence_ids, 534file.mimetype, 535db.session.get(PictureNature, nature_id)) 536db.session.add(resource) 537db.session.commit() 538file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 539pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 540resource.width, resource.height = pil_image.size 541db.session.commit() 542 543if flask.request.form.get("annotations"): 544try: 545resource.put_annotations(json.loads(flask.request.form.get("annotations"))) 546db.session.commit() 547except json.JSONDecodeError: 548flask.flash("Invalid annotations") 549 550flask.flash("Picture uploaded successfully") 551 552return flask.redirect("/picture/" + str(resource.id)) 553 554 555@app.route("/picture/<int:id>/") 556def picture(id): 557resource = db.session.get(PictureResource, id) 558if resource is None: 559flask.abort(404) 560 561image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 562 563current_user = db.session.get(User, flask.session.get("username")) 564have_permission = current_user and (current_user == resource.author or current_user.admin) 565 566own_rating = None 567if current_user: 568own_rating = PictureRating.query.filter_by(resource=resource, user=current_user).first() 569 570return flask.render_template("picture.html", resource=resource, 571file_extension=mimetypes.guess_extension(resource.file_format), 572size=image.size, copies=resource.copies, 573have_permission=have_permission, own_rating=own_rating) 574 575 576@app.route("/picture/<int:id>/annotate") 577def annotate_picture(id): 578resource = db.session.get(PictureResource, id) 579if resource is None: 580flask.abort(404) 581 582current_user = db.session.get(User, flask.session.get("username")) 583if current_user is None: 584flask.abort(401) 585 586if resource.author != current_user and not current_user.admin: 587flask.abort(403) 588 589return flask.render_template("picture-annotation.html", resource=resource, 590file_extension=mimetypes.guess_extension(resource.file_format)) 591 592 593@app.route("/picture/<int:id>/put-annotations-form") 594def put_annotations_form(id): 595resource = db.session.get(PictureResource, id) 596if resource is None: 597flask.abort(404) 598 599current_user = db.session.get(User, flask.session.get("username")) 600if current_user is None: 601flask.abort(401) 602 603if resource.author != current_user and not current_user.admin: 604flask.abort(403) 605 606return flask.render_template("put-annotations-form.html", resource=resource) 607 608 609@app.route("/picture/<int:id>/put-annotations-form", methods=["POST"]) 610def put_annotations_form_post(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 622resource.put_annotations(json.loads(flask.request.form["annotations"])) 623 624db.session.commit() 625 626return flask.redirect("/picture/" + str(resource.id)) 627 628 629@app.route("/picture/<int:id>/save-annotations", methods=["POST"]) 630@app.route("/api/picture/<int:id>/put-annotations", methods=["POST"]) 631def save_annotations(id): 632resource = db.session.get(PictureResource, id) 633if resource is None: 634flask.abort(404) 635 636current_user = db.session.get(User, flask.session.get("username")) 637if resource.author != current_user and not current_user.admin: 638flask.abort(403) 639 640resource.put_annotations(flask.request.json) 641 642db.session.commit() 643 644response = flask.make_response() 645response.status_code = 204 646return response 647 648 649@app.route("/picture/<int:id>/get-annotations") 650@app.route("/api/picture/<int:id>/api/get-annotations") 651def get_annotations(id): 652resource = db.session.get(PictureResource, id) 653if resource is None: 654flask.abort(404) 655 656regions = db.session.query(PictureRegion).filter_by(resource_id=id).all() 657 658regions_json = [] 659 660for region in regions: 661regions_json.append({ 662"object": region.object_id, 663"type": region.json["type"], 664"shape": region.json["shape"], 665}) 666 667return flask.jsonify(regions_json) 668 669 670@app.route("/picture/<int:id>/delete") 671def delete_picture(id): 672resource = db.session.get(PictureResource, id) 673if resource is None: 674flask.abort(404) 675 676current_user = db.session.get(User, flask.session.get("username")) 677if current_user is None: 678flask.abort(401) 679 680if resource.author != current_user and not current_user.admin: 681flask.abort(403) 682 683PictureLicence.query.filter_by(resource=resource).delete() 684PictureRegion.query.filter_by(resource=resource).delete() 685PictureInGallery.query.filter_by(resource=resource).delete() 686PictureRating.query.filter_by(resource=resource).delete() 687if resource.replaces: 688resource.replaces.replaced_by = None 689if resource.replaced_by: 690resource.replaced_by.replaces = None 691resource.copied_from = None 692for copy in resource.copies: 693copy.copied_from = None 694db.session.delete(resource) 695db.session.commit() 696 697return flask.redirect("/") 698 699 700@app.route("/picture/<int:id>/mark-replacement", methods=["POST"]) 701def mark_picture_replacement(id): 702resource = db.session.get(PictureResource, id) 703if resource is None: 704flask.abort(404) 705 706current_user = db.session.get(User, flask.session.get("username")) 707if current_user is None: 708flask.abort(401) 709 710if resource.copied_from.author != current_user and not current_user.admin: 711flask.abort(403) 712 713resource.copied_from.replaced_by = resource 714resource.replaces = resource.copied_from 715 716db.session.commit() 717 718return flask.redirect("/picture/" + str(resource.copied_from.id)) 719 720 721@app.route("/picture/<int:id>/remove-replacement", methods=["POST"]) 722def remove_picture_replacement(id): 723resource = db.session.get(PictureResource, id) 724if resource is None: 725flask.abort(404) 726 727current_user = db.session.get(User, flask.session.get("username")) 728if current_user is None: 729flask.abort(401) 730 731if resource.author != current_user and not current_user.admin: 732flask.abort(403) 733 734resource.replaced_by.replaces = None 735resource.replaced_by = None 736 737db.session.commit() 738 739return flask.redirect("/picture/" + str(resource.id)) 740 741 742@app.route("/picture/<int:id>/edit-metadata") 743def edit_picture(id): 744resource = db.session.get(PictureResource, id) 745if resource is None: 746flask.abort(404) 747 748current_user = db.session.get(User, flask.session.get("username")) 749if current_user is None: 750flask.abort(401) 751 752if resource.author != current_user and not current_user.admin: 753flask.abort(403) 754 755licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), 756Licence.title).all() 757 758types = PictureNature.query.all() 759 760return flask.render_template("edit-picture.html", resource=resource, licences=licences, 761types=types, 762PictureLicence=PictureLicence) 763 764 765@app.route("/picture/<int:id>/rate", methods=["POST"]) 766def rate_picture(id): 767resource = db.session.get(PictureResource, id) 768if resource is None: 769flask.abort(404) 770 771current_user = db.session.get(User, flask.session.get("username")) 772if current_user is None: 773flask.abort(401) 774 775rating = int(flask.request.form.get("rating")) 776 777if not rating: 778# Delete the existing rating 779if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 780db.session.delete(PictureRating.query.filter_by(resource=resource, 781user=current_user).first()) 782db.session.commit() 783 784return flask.redirect("/picture/" + str(resource.id)) 785 786if not 1 <= rating <= 5: 787flask.flash("Invalid rating") 788return flask.redirect("/picture/" + str(resource.id)) 789 790if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 791PictureRating.query.filter_by(resource=resource, user=current_user).first().rating = rating 792else: 793# Create a new rating 794db.session.add(PictureRating(resource, current_user, rating)) 795 796db.session.commit() 797 798return flask.redirect("/picture/" + str(resource.id)) 799 800 801@app.route("/picture/<int:id>/edit-metadata", methods=["POST"]) 802def edit_picture_post(id): 803resource = db.session.get(PictureResource, id) 804if resource is None: 805flask.abort(404) 806 807current_user = db.session.get(User, flask.session.get("username")) 808if current_user is None: 809flask.abort(401) 810 811if resource.author != current_user and not current_user.admin: 812flask.abort(403) 813 814title = flask.request.form["title"] 815description = flask.request.form["description"] 816origin_url = flask.request.form["origin_url"] 817licence_ids = flask.request.form.getlist("licence") 818nature_id = flask.request.form["nature"] 819 820if not title: 821flask.flash("Enter a title") 822return flask.redirect(flask.request.url) 823 824if not description: 825description = "" 826 827if not nature_id: 828flask.flash("Select a picture type") 829return flask.redirect(flask.request.url) 830 831if not licence_ids: 832flask.flash("Select licences") 833return flask.redirect(flask.request.url) 834 835licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 836if not any(licence.free for licence in licences): 837flask.flash("Select at least one free licence") 838return flask.redirect(flask.request.url) 839 840resource.title = title 841resource.description = description 842resource.origin_url = origin_url 843for licence_id in licence_ids: 844joiner = PictureLicence(resource, db.session.get(Licence, licence_id)) 845db.session.add(joiner) 846resource.nature = db.session.get(PictureNature, nature_id) 847 848db.session.commit() 849 850return flask.redirect("/picture/" + str(resource.id)) 851 852 853@app.route("/picture/<int:id>/copy") 854def copy_picture(id): 855resource = db.session.get(PictureResource, id) 856if resource is None: 857flask.abort(404) 858 859current_user = db.session.get(User, flask.session.get("username")) 860if current_user is None: 861flask.abort(401) 862 863new_resource = PictureResource(resource.title, current_user, resource.description, 864resource.origin_url, 865[licence.licence_id for licence in resource.licences], 866resource.file_format, 867resource.nature) 868 869for region in resource.regions: 870db.session.add(PictureRegion(region.json, new_resource, region.object)) 871 872db.session.commit() 873 874# Create a hard link for the new picture 875old_path = path.join(config.DATA_PATH, "pictures", str(resource.id)) 876new_path = path.join(config.DATA_PATH, "pictures", str(new_resource.id)) 877os.link(old_path, new_path) 878 879new_resource.width = resource.width 880new_resource.height = resource.height 881new_resource.copied_from = resource 882 883db.session.commit() 884 885return flask.redirect("/picture/" + str(new_resource.id)) 886 887 888@app.route("/gallery/<int:id>/") 889def gallery(id): 890gallery = db.session.get(Gallery, id) 891if gallery is None: 892flask.abort(404) 893 894current_user = db.session.get(User, flask.session.get("username")) 895 896have_permission = current_user and (current_user == gallery.owner or current_user.admin or UserInGallery.query.filter_by(user=current_user, gallery=gallery).first()) 897have_extended_permission = current_user and (current_user == gallery.owner or current_user.admin) 898 899return flask.render_template("gallery.html", gallery=gallery, 900have_permission=have_permission, 901have_extended_permission=have_extended_permission) 902 903 904@app.route("/create-gallery") 905def create_gallery(): 906if "username" not in flask.session: 907flask.flash("Log in to create galleries.") 908return flask.redirect("/accounts") 909 910return flask.render_template("create-gallery.html") 911 912 913@app.route("/create-gallery", methods=["POST"]) 914def create_gallery_post(): 915if not flask.session.get("username"): 916flask.abort(401) 917 918if not flask.request.form.get("title"): 919flask.flash("Enter a title") 920return flask.redirect(flask.request.url) 921 922description = flask.request.form.get("description", "") 923 924gallery = Gallery(flask.request.form["title"], description, 925db.session.get(User, flask.session["username"])) 926db.session.add(gallery) 927db.session.commit() 928 929return flask.redirect("/gallery/" + str(gallery.id)) 930 931 932@app.route("/gallery/<int:id>/add-picture", methods=["POST"]) 933def gallery_add_picture(id): 934gallery = db.session.get(Gallery, id) 935if gallery is None: 936flask.abort(404) 937 938if "username" not in flask.session: 939flask.abort(401) 940 941if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 942flask.abort(403) 943 944picture_id = flask.request.form.get("picture_id") 945if "/" in picture_id: # also allow full URLs 946picture_id = picture_id.rstrip("/").rpartition("/")[1] 947if not picture_id: 948flask.flash("Select a picture") 949return flask.redirect("/gallery/" + str(gallery.id)) 950picture_id = int(picture_id) 951 952picture = db.session.get(PictureResource, picture_id) 953if picture is None: 954flask.flash("Invalid picture") 955return flask.redirect("/gallery/" + str(gallery.id)) 956 957if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 958flask.flash("This picture is already in the gallery") 959return flask.redirect("/gallery/" + str(gallery.id)) 960 961db.session.add(PictureInGallery(picture, gallery)) 962 963db.session.commit() 964 965return flask.redirect("/gallery/" + str(gallery.id)) 966 967 968@app.route("/gallery/<int:id>/remove-picture", methods=["POST"]) 969def gallery_remove_picture(id): 970gallery = db.session.get(Gallery, id) 971if gallery is None: 972flask.abort(404) 973 974if "username" not in flask.session: 975flask.abort(401) 976 977current_user = db.session.get(User, flask.session.get("username")) 978 979if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 980flask.abort(403) 981 982picture_id = int(flask.request.form.get("picture_id")) 983 984picture = db.session.get(PictureResource, picture_id) 985if picture is None: 986flask.flash("Invalid picture") 987return flask.redirect("/gallery/" + str(gallery.id)) 988 989picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, 990gallery=gallery).first() 991if picture_in_gallery is None: 992flask.flash("This picture isn't in the gallery") 993return flask.redirect("/gallery/" + str(gallery.id)) 994 995db.session.delete(picture_in_gallery) 996 997db.session.commit() 998 999return flask.redirect("/gallery/" + str(gallery.id)) 1000 1001 1002@app.route("/gallery/<int:id>/add-pictures-from-query", methods=["POST"]) 1003def gallery_add_from_query(id): 1004gallery = db.session.get(Gallery, id) 1005if gallery is None: 1006flask.abort(404) 1007 1008if "username" not in flask.session: 1009flask.abort(401) 1010 1011if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1012flask.abort(403) 1013 1014query_yaml = flask.request.form.get("query", "") 1015 1016yaml_parser = yaml.YAML() 1017query_data = yaml_parser.load(query_yaml) or {} 1018query = get_picture_query(query_data) 1019 1020pictures = query.all() 1021 1022count = 0 1023 1024for picture in pictures: 1025if not PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 1026db.session.add(PictureInGallery(picture, gallery)) 1027count += 1 1028 1029db.session.commit() 1030 1031flask.flash(f"Added {count} pictures to the gallery") 1032 1033return flask.redirect("/gallery/" + str(gallery.id)) 1034 1035 1036@app.route("/gallery/<int:id>/users") 1037def gallery_users(id): 1038gallery = db.session.get(Gallery, id) 1039if gallery is None: 1040flask.abort(404) 1041 1042current_user = db.session.get(User, flask.session.get("username")) 1043have_permission = current_user and (current_user == gallery.owner or current_user.admin) 1044 1045return flask.render_template("gallery-users.html", gallery=gallery, 1046have_permission=have_permission) 1047 1048 1049@app.route("/gallery/<int:id>/edit") 1050def edit_gallery(id): 1051gallery = db.session.get(Gallery, id) 1052if gallery is None: 1053flask.abort(404) 1054 1055current_user = db.session.get(User, flask.session.get("username")) 1056if current_user is None: 1057flask.abort(401) 1058 1059if current_user != gallery.owner and not current_user.admin: 1060flask.abort(403) 1061 1062return flask.render_template("edit-gallery.html", gallery=gallery) 1063 1064 1065@app.route("/gallery/<int:id>/edit", methods=["POST"]) 1066def edit_gallery_post(id): 1067gallery = db.session.get(Gallery, id) 1068if gallery is None: 1069flask.abort(404) 1070 1071current_user = db.session.get(User, flask.session.get("username")) 1072if current_user is None: 1073flask.abort(401) 1074 1075if current_user != gallery.owner and not current_user.admin: 1076flask.abort(403) 1077 1078title = flask.request.form["title"] 1079description = flask.request.form.get("description") 1080 1081if not title: 1082flask.flash("Enter a title") 1083return flask.redirect(flask.request.url) 1084 1085if not description: 1086description = "" 1087 1088gallery.title = title 1089gallery.description = description 1090 1091db.session.commit() 1092 1093return flask.redirect("/gallery/" + str(gallery.id)) 1094 1095 1096@app.route("/gallery/<int:id>/delete") 1097def delete_gallery(id): 1098gallery = db.session.get(Gallery, id) 1099if gallery is None: 1100flask.abort(404) 1101 1102current_user = db.session.get(User, flask.session.get("username")) 1103if current_user is None: 1104flask.abort(401) 1105 1106if current_user != gallery.owner and not current_user.admin: 1107flask.abort(403) 1108 1109PictureInGallery.query.filter_by(gallery=gallery).delete() 1110UserInGallery.query.filter_by(gallery=gallery).delete() 1111db.session.delete(gallery) 1112db.session.commit() 1113 1114return flask.redirect("/") 1115 1116 1117@app.route("/gallery/<int:id>/users/add", methods=["POST"]) 1118def gallery_add_user(id): 1119gallery = db.session.get(Gallery, id) 1120if gallery is None: 1121flask.abort(404) 1122 1123current_user = db.session.get(User, flask.session.get("username")) 1124if current_user is None: 1125flask.abort(401) 1126 1127if current_user != gallery.owner and not current_user.admin: 1128flask.abort(403) 1129 1130username = flask.request.form.get("username") 1131if username == gallery.owner_name: 1132flask.flash("The owner is already in the gallery") 1133return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1134 1135user = db.session.get(User, username) 1136if user is None: 1137flask.flash("User not found") 1138return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1139 1140if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 1141flask.flash("User is already in the gallery") 1142return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1143 1144db.session.add(UserInGallery(user, gallery)) 1145 1146db.session.commit() 1147 1148return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1149 1150 1151@app.route("/gallery/<int:id>/users/remove", methods=["POST"]) 1152def gallery_remove_user(id): 1153gallery = db.session.get(Gallery, id) 1154if gallery is None: 1155flask.abort(404) 1156 1157current_user = db.session.get(User, flask.session.get("username")) 1158if current_user is None: 1159flask.abort(401) 1160 1161if current_user != gallery.owner and not current_user.admin: 1162flask.abort(403) 1163 1164username = flask.request.form.get("username") 1165user = db.session.get(User, username) 1166if user is None: 1167flask.flash("User not found") 1168return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1169 1170user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 1171if user_in_gallery is None: 1172flask.flash("User is not in the gallery") 1173return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1174 1175db.session.delete(user_in_gallery) 1176 1177db.session.commit() 1178 1179return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1180 1181 1182class APIError(Exception): 1183def __init__(self, status_code, message): 1184self.status_code = status_code 1185self.message = message 1186 1187 1188def get_picture_query(query_data): 1189query = db.session.query(PictureResource) 1190 1191def has_condition(id): 1192descendants_cte = ( 1193db.select(PictureObject.id) 1194.where(PictureObject.id == id) 1195.cte(name=f"descendants_cte_{id}", recursive=True) 1196) 1197 1198descendants_cte = descendants_cte.union_all( 1199db.select(PictureObjectInheritance.child_id) 1200.where(PictureObjectInheritance.parent_id == descendants_cte.c.id) 1201) 1202 1203return PictureResource.regions.any( 1204PictureRegion.object_id.in_( 1205db.select(descendants_cte.c.id) 1206) 1207) 1208 1209requirement_conditions = { 1210# Has an object with the ID in the given list 1211"has_object": lambda value: PictureResource.regions.any( 1212PictureRegion.object_id.in_(value)), 1213# Has an object with the ID in the given list, or a subtype of it 1214"has": lambda value: db.or_(*[has_condition(id) for id in value]), 1215"nature": lambda value: PictureResource.nature_id.in_(value), 1216"licence": lambda value: PictureResource.licences.any( 1217PictureLicence.licence_id.in_(value)), 1218"author": lambda value: PictureResource.author_name.in_(value), 1219"title": lambda value: PictureResource.title.ilike(value), 1220"description": lambda value: PictureResource.description.ilike(value), 1221"origin_url": lambda value: db.func.lower(db.func.substr( 1222PictureResource.origin_url, 1223db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 1224)).in_(value), 1225"above_width": lambda value: PictureResource.width >= value, 1226"below_width": lambda value: PictureResource.width <= value, 1227"above_height": lambda value: PictureResource.height >= value, 1228"below_height": lambda value: PictureResource.height <= value, 1229"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 1230value), 1231"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 1232value), 1233"in_gallery": lambda value: PictureResource.galleries.any(PictureInGallery.gallery_id.in_(value)), 1234"above_rating": lambda value: db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 5)).where(PictureRating.resource_id == PictureResource.id).scalar_subquery() >= value, 1235"below_rating": lambda value: db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 0)).where(PictureRating.resource_id == PictureResource.id).scalar_subquery() <= value, 1236"above_rating_count": lambda value: db.select(db.func.count(PictureRating.id)).where(PictureRating.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() >= value, 1237"below_rating_count": lambda value: db.select(db.func.count(PictureRating.id)).where(PictureRating.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() <= value, 1238"above_region_count": lambda value: db.select(db.func.count(PictureRegion.id)).where(PictureRegion.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() >= value, 1239"below_region_count": lambda value: db.select(db.func.count(PictureRegion.id)).where(PictureRegion.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() <= value, 1240"copied_from": lambda value: PictureResource.copied_from_id.in_(value), 1241} 1242 1243if "want" in query_data: 1244for i in query_data["want"]: 1245if len(i) != 1: 1246raise APIError(400, "Each requirement must have exactly one key") 1247requirement, value = list(i.items())[0] 1248if requirement not in requirement_conditions: 1249raise APIError(400, f"Unknown requirement type: {requirement}") 1250 1251condition = requirement_conditions[requirement] 1252query = query.filter(condition(value)) 1253if "exclude" in query_data: 1254for i in query_data["exclude"]: 1255if len(i) != 1: 1256raise APIError(400, "Each exclusion must have exactly one key") 1257requirement, value = list(i.items())[0] 1258if requirement not in requirement_conditions: 1259raise APIError(400, f"Unknown requirement type: {requirement}") 1260 1261condition = requirement_conditions[requirement] 1262query = query.filter(~condition(value)) 1263if not query_data.get("include_obsolete", False): 1264query = query.filter(PictureResource.replaced_by_id.is_(None)) 1265 1266return query 1267 1268 1269@app.route("/query-pictures") 1270def graphical_query_pictures(): 1271return flask.render_template("graphical-query-pictures.html") 1272 1273 1274@app.route("/query-pictures-results") 1275def graphical_query_pictures_results(): 1276query_yaml = flask.request.args.get("query", "") 1277yaml_parser = yaml.YAML() 1278query_data = yaml_parser.load(query_yaml) or {} 1279try: 1280query = get_picture_query(query_data) 1281except APIError as e: 1282flask.abort(e.status_code) 1283 1284page = int(flask.request.args.get("page", 1)) 1285per_page = int(flask.request.args.get("per_page", 16)) 1286 1287resources = query.paginate(page=page, per_page=per_page) 1288 1289return flask.render_template("graphical-query-pictures-results.html", resources=resources, 1290query=query_yaml, 1291page_number=page, page_length=per_page, 1292num_pages=resources.pages, 1293prev_page=resources.prev_num, next_page=resources.next_num) 1294 1295 1296@app.route("/raw/picture/<int:id>") 1297def raw_picture(id): 1298resource = db.session.get(PictureResource, id) 1299if resource is None: 1300flask.abort(404) 1301 1302response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), 1303str(resource.id)) 1304response.mimetype = resource.file_format 1305 1306return response 1307 1308 1309@app.route("/object/") 1310def graphical_object_types(): 1311return flask.render_template("object-types.html", objects=PictureObject.query.all()) 1312 1313 1314@app.route("/api/object-types") 1315def object_types(): 1316objects = db.session.query(PictureObject).all() 1317return flask.jsonify({object.id: object.description for object in objects}) 1318 1319 1320@app.route("/api/query-pictures", methods=["POST"]) # sadly GET can't have a body 1321def query_pictures(): 1322offset = int(flask.request.args.get("offset", 0)) 1323limit = int(flask.request.args.get("limit", 16)) 1324ordering = flask.request.args.get("ordering", "date-desc") 1325 1326yaml_parser = yaml.YAML() 1327query_data = yaml_parser.load(flask.request.data) or {} 1328try: 1329query = get_picture_query(query_data) 1330except APIError as e: 1331return flask.jsonify({"error": e.message}), e.status_code 1332 1333rating_count_subquery = db.select(db.func.count(PictureRating.id)).where( 1334PictureRating.resource_id == PictureResource.id).scalar_subquery() 1335region_count_subquery = db.select(db.func.count(PictureRegion.id)).where( 1336PictureRegion.resource_id == PictureResource.id).scalar_subquery() 1337rating_subquery = db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 0)).where( 1338PictureRating.resource_id == PictureResource.id).scalar_subquery() 1339 1340match ordering: 1341case "date-desc": 1342query = query.order_by(PictureResource.timestamp.desc()) 1343case "date-asc": 1344query = query.order_by(PictureResource.timestamp.asc()) 1345case "title-asc": 1346query = query.order_by(PictureResource.title.asc()) 1347case "title-desc": 1348query = query.order_by(PictureResource.title.desc()) 1349case "random": 1350query = query.order_by(db.func.random()) 1351case "number-regions-desc": 1352query = query.order_by(region_count_subquery.desc()) 1353case "number-regions-asc": 1354query = query.order_by(region_count_subquery.asc()) 1355case "rating-desc": 1356query = query.order_by(rating_subquery.desc()) 1357case "rating-asc": 1358query = query.order_by(rating_subquery.asc()) 1359case "number-ratings-desc": 1360query = query.order_by(rating_count_subquery.desc()) 1361case "number-ratings-asc": 1362query = query.order_by(rating_count_subquery.asc()) 1363 1364query = query.offset(offset).limit(limit) 1365resources = query.all() 1366 1367json_response = { 1368"date_generated": datetime.utcnow().timestamp(), 1369"resources": [], 1370"offset": offset, 1371"limit": limit, 1372} 1373 1374json_resources = json_response["resources"] 1375 1376for resource in resources: 1377json_resource = { 1378"id": resource.id, 1379"title": resource.title, 1380"description": resource.description, 1381"timestamp": resource.timestamp.timestamp(), 1382"origin_url": resource.origin_url, 1383"author": resource.author_name, 1384"file_format": resource.file_format, 1385"width": resource.width, 1386"height": resource.height, 1387"nature": resource.nature_id, 1388"licences": [licence.licence_id for licence in resource.licences], 1389"replaces": resource.replaces_id, 1390"replaced_by": resource.replaced_by_id, 1391"regions": [], 1392"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1393} 1394for region in resource.regions: 1395json_resource["regions"].append({ 1396"object": region.object_id, 1397"type": region.json["type"], 1398"shape": region.json["shape"], 1399}) 1400 1401json_resources.append(json_resource) 1402 1403return flask.jsonify(json_response) 1404 1405 1406@app.route("/api/picture/<int:id>/") 1407def api_picture(id): 1408resource = db.session.get(PictureResource, id) 1409if resource is None: 1410flask.abort(404) 1411 1412json_resource = { 1413"id": resource.id, 1414"title": resource.title, 1415"description": resource.description, 1416"timestamp": resource.timestamp.timestamp(), 1417"origin_url": resource.origin_url, 1418"author": resource.author_name, 1419"file_format": resource.file_format, 1420"width": resource.width, 1421"height": resource.height, 1422"nature": resource.nature_id, 1423"licences": [licence.licence_id for licence in resource.licences], 1424"replaces": resource.replaces_id, 1425"replaced_by": resource.replaced_by_id, 1426"regions": [], 1427"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1428"rating_average": resource.average_rating, 1429"rating_count": resource.rating_totals, 1430} 1431for region in resource.regions: 1432json_resource["regions"].append({ 1433"object": region.object_id, 1434"type": region.json["type"], 1435"shape": region.json["shape"], 1436}) 1437 1438return flask.jsonify(json_resource) 1439 1440 1441@app.route("/api/licence/") 1442def api_licences(): 1443licences = db.session.query(Licence).all() 1444json_licences = { 1445licence.id: { 1446"title": licence.title, 1447"free": licence.free, 1448"pinned": licence.pinned, 1449} for licence in licences 1450} 1451 1452return flask.jsonify(json_licences) 1453 1454 1455@app.route("/api/licence/<id>/") 1456def api_licence(id): 1457licence = db.session.get(Licence, id) 1458if licence is None: 1459flask.abort(404) 1460 1461json_licence = { 1462"id": licence.id, 1463"title": licence.title, 1464"description": licence.description, 1465"info_url": licence.info_url, 1466"legalese_url": licence.url, 1467"free": licence.free, 1468"logo_url": licence.logo_url, 1469"pinned": licence.pinned, 1470} 1471 1472return flask.jsonify(json_licence) 1473 1474 1475@app.route("/api/nature/") 1476def api_natures(): 1477natures = db.session.query(PictureNature).all() 1478json_natures = { 1479nature.id: nature.description for nature in natures 1480} 1481 1482return flask.jsonify(json_natures) 1483 1484 1485@app.route("/api/user/") 1486def api_users(): 1487offset = int(flask.request.args.get("offset", 0)) 1488limit = int(flask.request.args.get("limit", 16)) 1489 1490users = db.session.query(User).offset(offset).limit(limit).all() 1491 1492json_users = { 1493user.username: { 1494"admin": user.admin, 1495} for user in users 1496} 1497 1498return flask.jsonify(json_users) 1499 1500 1501@app.route("/api/user/<username>/") 1502def api_user(username): 1503user = db.session.get(User, username) 1504if user is None: 1505flask.abort(404) 1506 1507json_user = { 1508"username": user.username, 1509"admin": user.admin, 1510"joined": user.joined_timestamp.timestamp(), 1511} 1512 1513return flask.jsonify(json_user) 1514 1515 1516@app.route("/api/login", methods=["POST"]) 1517def api_login(): 1518username = flask.request.json["username"] 1519password = flask.request.json["password"] 1520 1521user = db.session.get(User, username) 1522 1523if user is None: 1524return flask.jsonify({"error": "This username is not registered. To prevent spam, you must use the HTML interface to register."}), 401 1525 1526if not bcrypt.check_password_hash(user.password_hashed, password): 1527return flask.jsonify({"error": "Incorrect password"}), 401 1528 1529flask.session["username"] = username 1530 1531return flask.jsonify({"message": "You have been logged in. Your HTTP client must support cookies to use features of this API that require authentication."}) 1532 1533 1534@app.route("/api/logout", methods=["POST"]) 1535def api_logout(): 1536flask.session.pop("username", None) 1537return flask.jsonify({"message": "You have been logged out."}) 1538 1539 1540@app.route("/api/upload", methods=["POST"]) 1541def api_upload(): 1542if "username" not in flask.session: 1543return flask.jsonify({"error": "You must be logged in to upload pictures"}), 401 1544 1545json_ = json.loads(flask.request.form["json"]) 1546title = json_["title"] 1547description = json_.get("description", "") 1548origin_url = json_.get("origin_url", "") 1549author = db.session.get(User, flask.session["username"]) 1550licence_ids = json_["licence"] 1551nature_id = json_["nature"] 1552file = flask.request.files["file"] 1553 1554if not file or not file.filename: 1555return flask.jsonify({"error": "An image file must be uploaded"}), 400 1556 1557if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 1558return flask.jsonify({"error": "Only bitmap images are supported"}), 400 1559 1560if not title: 1561return flask.jsonify({"error": "Give a title"}), 400 1562 1563if not description: 1564description = "" 1565 1566if not nature_id: 1567return flask.jsonify({"error": "Give a picture type"}), 400 1568 1569if not licence_ids: 1570return flask.jsonify({"error": "Give licences"}), 400 1571 1572licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1573if not any(licence.free for licence in licences): 1574return flask.jsonify({"error": "Use at least one free licence"}), 400 1575 1576resource = PictureResource(title, author, description, origin_url, licence_ids, 1577file.mimetype, 1578db.session.get(PictureNature, nature_id)) 1579db.session.add(resource) 1580db.session.commit() 1581file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1582pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1583resource.width, resource.height = pil_image.size 1584db.session.commit() 1585 1586if json_.get("annotations"): 1587try: 1588resource.put_annotations(json_["annotations"]) 1589db.session.commit() 1590except json.JSONDecodeError: 1591return flask.jsonify({"error": "Invalid annotations"}), 400 1592 1593return flask.jsonify({"message": "Picture uploaded successfully", "id": resource.id}) 1594 1595 1596@app.route("/api/picture/<int:id>/update", methods=["POST"]) 1597def api_update_picture(id): 1598resource = db.session.get(PictureResource, id) 1599if resource is None: 1600return flask.jsonify({"error": "Picture not found"}), 404 1601current_user = db.session.get(User, flask.session.get("username")) 1602if current_user is None: 1603return flask.jsonify({"error": "You must be logged in to edit pictures"}), 401 1604if resource.author != current_user and not current_user.admin: 1605return flask.jsonify({"error": "You are not the author of this picture"}), 403 1606 1607title = flask.request.json.get("title", resource.title) 1608description = flask.request.json.get("description", resource.description) 1609origin_url = flask.request.json.get("origin_url", resource.origin_url) 1610licence_ids = flask.request.json.get("licence", [licence.licence_id for licence in resource.licences]) 1611nature_id = flask.request.json.get("nature", resource.nature_id) 1612 1613if not title: 1614return flask.jsonify({"error": "Give a title"}), 400 1615 1616if not description: 1617description = "" 1618 1619if not nature_id: 1620return flask.jsonify({"error": "Give a picture type"}), 400 1621 1622if not licence_ids: 1623return flask.jsonify({"error": "Give licences"}), 400 1624 1625licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1626 1627if not any(licence.free for licence in licences): 1628return flask.jsonify({"error": "Use at least one free licence"}), 400 1629 1630resource.title = title 1631resource.description = description 1632resource.origin_url = origin_url 1633resource.licences = licences 1634resource.nature = db.session.get(PictureNature, nature_id) 1635 1636db.session.commit() 1637 1638return flask.jsonify({"message": "Picture updated successfully"}) 1639 1640 1641@app.route("/api/picture/<int:id>/rate", methods=["POST"]) 1642def api_rate_picture(id): 1643resource = db.session.get(PictureResource, id) 1644if resource is None: 1645flask.abort(404) 1646 1647current_user = db.session.get(User, flask.session.get("username")) 1648if current_user is None: 1649flask.abort(401) 1650 1651rating = int(flask.request.json.get("rating", 0)) 1652 1653if not rating: 1654# Delete the existing rating 1655if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 1656db.session.delete(PictureRating.query.filter_by(resource=resource, 1657user=current_user).first()) 1658db.session.commit() 1659 1660return flask.jsonify({"message": "Existing rating removed"}) 1661 1662if not 1 <= rating <= 5: 1663flask.flash("Invalid rating") 1664return flask.jsonify({"error": "Invalid rating"}), 400 1665 1666if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 1667PictureRating.query.filter_by(resource=resource, user=current_user).first().rating = rating 1668else: 1669# Create a new rating 1670db.session.add(PictureRating(resource, current_user, rating)) 1671 1672db.session.commit() 1673 1674return flask.jsonify({"message": "Rating saved"}) 1675 1676 1677@app.route("/api/gallery/<int:id>/") 1678def api_gallery(id): 1679gallery = db.session.get(Gallery, id) 1680if gallery is None: 1681flask.abort(404) 1682 1683json_gallery = { 1684"id": gallery.id, 1685"title": gallery.title, 1686"description": gallery.description, 1687"owner": gallery.owner_name, 1688"users": [user.username for user in gallery.users], 1689} 1690 1691return flask.jsonify(json_gallery) 1692 1693 1694@app.route("/api/gallery/<int:id>/edit", methods=["POST"]) 1695def api_edit_gallery(id): 1696gallery = db.session.get(Gallery, id) 1697if gallery is None: 1698flask.abort(404) 1699 1700current_user = db.session.get(User, flask.session.get("username")) 1701if current_user is None: 1702flask.abort(401) 1703 1704if current_user != gallery.owner and not current_user.admin: 1705flask.abort(403) 1706 1707title = flask.request.json.get("title", gallery.title) 1708description = flask.request.json.get("description", gallery.description) 1709 1710if not title: 1711return flask.jsonify({"error": "Give a title"}), 400 1712 1713if not description: 1714description = "" 1715 1716gallery.title = title 1717gallery.description = description 1718 1719db.session.commit() 1720 1721return flask.jsonify({"message": "Gallery updated successfully"}) 1722 1723 1724@app.route("/api/new-gallery", methods=["POST"]) 1725def api_new_gallery(): 1726if "username" not in flask.session: 1727return flask.jsonify({"error": "You must be logged in to create galleries"}), 401 1728 1729title = flask.request.json.get("title") 1730description = flask.request.json.get("description", "") 1731 1732if not title: 1733return flask.jsonify({"error": "Give a title"}), 400 1734 1735gallery = Gallery(title, description, db.session.get(User, flask.session["username"])) 1736db.session.add(gallery) 1737db.session.commit() 1738 1739return flask.jsonify({"message": "Gallery created successfully", "id": gallery.id}) 1740 1741 1742@app.route("/api/gallery/<int:id>/add-picture", methods=["POST"]) 1743def api_gallery_add_picture(id): 1744gallery = db.session.get(Gallery, id) 1745if gallery is None: 1746flask.abort(404) 1747 1748if "username" not in flask.session: 1749return flask.jsonify({"error": "You must be logged in to add pictures to galleries"}), 401 1750 1751current_user = db.session.get(User, flask.session.get("username")) 1752 1753if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1754return flask.jsonify({"error": "You do not have permission to add pictures to this gallery"}), 403 1755 1756picture_id = flask.request.json.get("picture_id") 1757 1758try: 1759picture_id = int(picture_id) 1760except ValueError: 1761return flask.jsonify({"error": "Invalid picture ID"}), 400 1762 1763picture = db.session.get(PictureResource, picture_id) 1764if picture is None: 1765return flask.jsonify({"error": "The picture doesn't exist"}), 404 1766 1767if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 1768return flask.jsonify({"error": "This picture is already in the gallery"}), 400 1769 1770db.session.add(PictureInGallery(picture, gallery)) 1771 1772db.session.commit() 1773 1774return flask.jsonify({"message": "Picture added to gallery"}) 1775 1776 1777@app.route("/api/gallery/<int:id>/remove-picture", methods=["POST"]) 1778def api_gallery_remove_picture(id): 1779gallery = db.session.get(Gallery, id) 1780if gallery is None: 1781flask.abort(404) 1782 1783if "username" not in flask.session: 1784return flask.jsonify({"error": "You must be logged in to remove pictures from galleries"}), 401 1785 1786current_user = db.session.get(User, flask.session.get("username")) 1787 1788if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1789return flask.jsonify({"error": "You do not have permission to remove pictures from this gallery"}), 403 1790 1791picture_id = flask.request.json.get("picture_id") 1792 1793try: 1794picture_id = int(picture_id) 1795except ValueError: 1796return flask.jsonify({"error": "Invalid picture ID"}), 400 1797 1798picture = db.session.get(PictureResource, picture_id) 1799if picture is None: 1800return flask.jsonify({"error": "The picture doesn't exist"}), 404 1801 1802picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first() 1803if picture_in_gallery is None: 1804return flask.jsonify({"error": "This picture isn't in the gallery"}), 400 1805 1806db.session.delete(picture_in_gallery) 1807 1808db.session.commit() 1809 1810return flask.jsonify({"message": "Picture removed from gallery"}) 1811 1812 1813@app.route("/api/gallery/<int:id>/users/add", methods=["POST"]) 1814def api_gallery_add_user(id): 1815gallery = db.session.get(Gallery, id) 1816if gallery is None: 1817flask.abort(404) 1818 1819current_user = db.session.get(User, flask.session.get("username")) 1820if current_user is None: 1821flask.abort(401) 1822 1823if current_user != gallery.owner and not current_user.admin: 1824flask.abort(403) 1825 1826username = flask.request.json.get("username") 1827if username == gallery.owner_name: 1828return flask.jsonify({"error": "The owner cannot be added to trusted users"}), 400 1829 1830user = db.session.get(User, username) 1831if user is None: 1832return flask.jsonify({"error": "User not found"}), 404 1833 1834if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 1835return flask.jsonify({"error": "User is already in the gallery"}), 400 1836 1837db.session.add(UserInGallery(user, gallery)) 1838 1839db.session.commit() 1840 1841return flask.jsonify({"message": "User added to gallery"}) 1842 1843 1844@app.route("/api/gallery/<int:id>/users/remove", methods=["POST"]) 1845def api_gallery_remove_user(id): 1846gallery = db.session.get(Gallery, id) 1847if gallery is None: 1848flask.abort(404) 1849 1850current_user = db.session.get(User, flask.session.get("username")) 1851if current_user is None: 1852flask.abort(401) 1853 1854if current_user != gallery.owner and not current_user.admin: 1855flask.abort(403) 1856 1857username = flask.request.json.get("username") 1858user = db.session.get(User, username) 1859if user is None: 1860return flask.jsonify({"error": "User not found"}), 404 1861 1862user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 1863if user_in_gallery is None: 1864return flask.jsonify({"error": "User is not in the gallery"}), 400 1865 1866db.session.delete(user_in_gallery) 1867 1868db.session.commit() 1869 1870return flask.jsonify({"message": "User removed from gallery"}) 1871 1872 1873@app.route("/api/gallery/<int:id>/delete", methods=["POST"]) 1874def api_delete_gallery(id): 1875gallery = db.session.get(Gallery, id) 1876if gallery is None: 1877flask.abort(404) 1878 1879current_user = db.session.get(User, flask.session.get("username")) 1880if current_user is None: 1881flask.abort(401) 1882 1883if current_user != gallery.owner and not current_user.admin: 1884flask.abort(403) 1885 1886PictureInGallery.query.filter_by(gallery=gallery).delete() 1887UserInGallery.query.filter_by(gallery=gallery).delete() 1888db.session.delete(gallery) 1889 1890db.session.commit() 1891 1892return flask.jsonify({"message": "Gallery deleted"}) 1893 1894