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