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 17from sqlalchemy.sql.functions import current_user 18 19app = flask.Flask(__name__) 20bcrypt = Bcrypt(app) 21 22app.config["SQLALCHEMY_DATABASE_URI"] = config.DB_URI 23app.config["SECRET_KEY"] = config.DB_PASSWORD 24 25db = SQLAlchemy(app) 26migrate = Migrate(app, db) 27 28 29@app.template_filter("split") 30def split(value, separator=None, maxsplit=-1): 31return value.split(separator, maxsplit) 32 33 34@app.template_filter("median") 35def median(value): 36value = list(value) # prevent generators 37return sorted(value)[len(value) // 2] 38 39 40@app.template_filter("set") 41def set_filter(value): 42return set(value) 43 44 45@app.template_global() 46def modify_query(**new_values): 47args = flask.request.args.copy() 48for key, value in new_values.items(): 49args[key] = value 50 51return f"{flask.request.path}?{urlencode(args)}" 52 53 54@app.context_processor 55def default_variables(): 56return { 57"current_user": db.session.get(User, flask.session.get("username")), 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()) 897 898return flask.render_template("gallery.html", gallery=gallery, 899have_permission=have_permission) 900 901 902@app.route("/create-gallery") 903def create_gallery(): 904if "username" not in flask.session: 905flask.flash("Log in to create galleries.") 906return flask.redirect("/accounts") 907 908return flask.render_template("create-gallery.html") 909 910 911@app.route("/create-gallery", methods=["POST"]) 912def create_gallery_post(): 913if not flask.session.get("username"): 914flask.abort(401) 915 916if not flask.request.form.get("title"): 917flask.flash("Enter a title") 918return flask.redirect(flask.request.url) 919 920description = flask.request.form.get("description", "") 921 922gallery = Gallery(flask.request.form["title"], description, 923db.session.get(User, flask.session["username"])) 924db.session.add(gallery) 925db.session.commit() 926 927return flask.redirect("/gallery/" + str(gallery.id)) 928 929 930@app.route("/gallery/<int:id>/add-picture", methods=["POST"]) 931def gallery_add_picture(id): 932gallery = db.session.get(Gallery, id) 933if gallery is None: 934flask.abort(404) 935 936if "username" not in flask.session: 937flask.abort(401) 938 939if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 940flask.abort(403) 941 942picture_id = flask.request.form.get("picture_id") 943if "/" in picture_id: # also allow full URLs 944picture_id = picture_id.rstrip("/").rpartition("/")[1] 945if not picture_id: 946flask.flash("Select a picture") 947return flask.redirect("/gallery/" + str(gallery.id)) 948picture_id = int(picture_id) 949 950picture = db.session.get(PictureResource, picture_id) 951if picture is None: 952flask.flash("Invalid picture") 953return flask.redirect("/gallery/" + str(gallery.id)) 954 955if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 956flask.flash("This picture is already in the gallery") 957return flask.redirect("/gallery/" + str(gallery.id)) 958 959db.session.add(PictureInGallery(picture, gallery)) 960 961db.session.commit() 962 963return flask.redirect("/gallery/" + str(gallery.id)) 964 965 966@app.route("/gallery/<int:id>/remove-picture", methods=["POST"]) 967def gallery_remove_picture(id): 968gallery = db.session.get(Gallery, id) 969if gallery is None: 970flask.abort(404) 971 972if "username" not in flask.session: 973flask.abort(401) 974 975current_user = db.session.get(User, flask.session.get("username")) 976 977if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 978flask.abort(403) 979 980picture_id = int(flask.request.form.get("picture_id")) 981 982picture = db.session.get(PictureResource, picture_id) 983if picture is None: 984flask.flash("Invalid picture") 985return flask.redirect("/gallery/" + str(gallery.id)) 986 987picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, 988gallery=gallery).first() 989if picture_in_gallery is None: 990flask.flash("This picture isn't in the gallery") 991return flask.redirect("/gallery/" + str(gallery.id)) 992 993db.session.delete(picture_in_gallery) 994 995db.session.commit() 996 997return flask.redirect("/gallery/" + str(gallery.id)) 998 999 1000@app.route("/gallery/<int:id>/add-pictures-from-query", methods=["POST"]) 1001def gallery_add_from_query(id): 1002gallery = db.session.get(Gallery, id) 1003if gallery is None: 1004flask.abort(404) 1005 1006if "username" not in flask.session: 1007flask.abort(401) 1008 1009if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1010flask.abort(403) 1011 1012query_yaml = flask.request.form.get("query", "") 1013 1014yaml_parser = yaml.YAML() 1015query_data = yaml_parser.load(query_yaml) or {} 1016query = get_picture_query(query_data) 1017 1018pictures = query.all() 1019 1020count = 0 1021 1022for picture in pictures: 1023if not PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 1024db.session.add(PictureInGallery(picture, gallery)) 1025count += 1 1026 1027db.session.commit() 1028 1029flask.flash(f"Added {count} pictures to the gallery") 1030 1031return flask.redirect("/gallery/" + str(gallery.id)) 1032 1033 1034@app.route("/gallery/<int:id>/users") 1035def gallery_users(id): 1036gallery = db.session.get(Gallery, id) 1037if gallery is None: 1038flask.abort(404) 1039 1040current_user = db.session.get(User, flask.session.get("username")) 1041have_permission = current_user and (current_user == gallery.owner or current_user.admin) 1042 1043return flask.render_template("gallery-users.html", gallery=gallery, 1044have_permission=have_permission) 1045 1046 1047@app.route("/gallery/<int:id>/edit") 1048def edit_gallery(id): 1049gallery = db.session.get(Gallery, id) 1050if gallery is None: 1051flask.abort(404) 1052 1053current_user = db.session.get(User, flask.session.get("username")) 1054if current_user is None: 1055flask.abort(401) 1056 1057if current_user != gallery.owner and not current_user.admin: 1058flask.abort(403) 1059 1060return flask.render_template("edit-gallery.html", gallery=gallery) 1061 1062 1063@app.route("/gallery/<int:id>/edit", methods=["POST"]) 1064def edit_gallery_post(id): 1065gallery = db.session.get(Gallery, id) 1066if gallery is None: 1067flask.abort(404) 1068 1069current_user = db.session.get(User, flask.session.get("username")) 1070if current_user is None: 1071flask.abort(401) 1072 1073if current_user != gallery.owner and not current_user.admin: 1074flask.abort(403) 1075 1076title = flask.request.form["title"] 1077description = flask.request.form.get("description") 1078 1079if not title: 1080flask.flash("Enter a title") 1081return flask.redirect(flask.request.url) 1082 1083if not description: 1084description = "" 1085 1086gallery.title = title 1087gallery.description = description 1088 1089db.session.commit() 1090 1091return flask.redirect("/gallery/" + str(gallery.id)) 1092 1093 1094@app.route("/gallery/<int:id>/users/add", methods=["POST"]) 1095def gallery_add_user(id): 1096gallery = db.session.get(Gallery, id) 1097if gallery is None: 1098flask.abort(404) 1099 1100current_user = db.session.get(User, flask.session.get("username")) 1101if current_user is None: 1102flask.abort(401) 1103 1104if current_user != gallery.owner and not current_user.admin: 1105flask.abort(403) 1106 1107username = flask.request.form.get("username") 1108if username == gallery.owner_name: 1109flask.flash("The owner is already in the gallery") 1110return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1111 1112user = db.session.get(User, username) 1113if user is None: 1114flask.flash("User not found") 1115return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1116 1117if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 1118flask.flash("User is already in the gallery") 1119return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1120 1121db.session.add(UserInGallery(user, gallery)) 1122 1123db.session.commit() 1124 1125return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1126 1127 1128@app.route("/gallery/<int:id>/users/remove", methods=["POST"]) 1129def gallery_remove_user(id): 1130gallery = db.session.get(Gallery, id) 1131if gallery is None: 1132flask.abort(404) 1133 1134current_user = db.session.get(User, flask.session.get("username")) 1135if current_user is None: 1136flask.abort(401) 1137 1138if current_user != gallery.owner and not current_user.admin: 1139flask.abort(403) 1140 1141username = flask.request.form.get("username") 1142user = db.session.get(User, username) 1143if user is None: 1144flask.flash("User not found") 1145return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1146 1147user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 1148if user_in_gallery is None: 1149flask.flash("User is not in the gallery") 1150return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1151 1152db.session.delete(user_in_gallery) 1153 1154db.session.commit() 1155 1156return flask.redirect("/gallery/" + str(gallery.id) + "/users") 1157 1158 1159class APIError(Exception): 1160def __init__(self, status_code, message): 1161self.status_code = status_code 1162self.message = message 1163 1164 1165def get_picture_query(query_data): 1166query = db.session.query(PictureResource) 1167 1168def has_condition(id): 1169descendants_cte = ( 1170db.select(PictureObject.id) 1171.where(PictureObject.id == id) 1172.cte(name=f"descendants_cte_{id}", recursive=True) 1173) 1174 1175descendants_cte = descendants_cte.union_all( 1176db.select(PictureObjectInheritance.child_id) 1177.where(PictureObjectInheritance.parent_id == descendants_cte.c.id) 1178) 1179 1180return PictureResource.regions.any( 1181PictureRegion.object_id.in_( 1182db.select(descendants_cte.c.id) 1183) 1184) 1185 1186requirement_conditions = { 1187# Has an object with the ID in the given list 1188"has_object": lambda value: PictureResource.regions.any( 1189PictureRegion.object_id.in_(value)), 1190# Has an object with the ID in the given list, or a subtype of it 1191"has": lambda value: db.or_(*[has_condition(id) for id in value]), 1192"nature": lambda value: PictureResource.nature_id.in_(value), 1193"licence": lambda value: PictureResource.licences.any( 1194PictureLicence.licence_id.in_(value)), 1195"author": lambda value: PictureResource.author_name.in_(value), 1196"title": lambda value: PictureResource.title.ilike(value), 1197"description": lambda value: PictureResource.description.ilike(value), 1198"origin_url": lambda value: db.func.lower(db.func.substr( 1199PictureResource.origin_url, 1200db.func.length(db.func.split_part(PictureResource.origin_url, "://", 1)) + 4 1201)).in_(value), 1202"above_width": lambda value: PictureResource.width >= value, 1203"below_width": lambda value: PictureResource.width <= value, 1204"above_height": lambda value: PictureResource.height >= value, 1205"below_height": lambda value: PictureResource.height <= value, 1206"before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( 1207value), 1208"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( 1209value), 1210"in_gallery": lambda value: PictureResource.galleries.any(PictureInGallery.gallery_id.in_(value)), 1211"above_rating": lambda value: db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 5)).where(PictureRating.resource_id == PictureResource.id).scalar_subquery() >= value, 1212"below_rating": lambda value: db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 0)).where(PictureRating.resource_id == PictureResource.id).scalar_subquery() <= value, 1213"above_rating_count": lambda value: db.select(db.func.count(PictureRating.id)).where(PictureRating.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() >= value, 1214"below_rating_count": lambda value: db.select(db.func.count(PictureRating.id)).where(PictureRating.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() <= value, 1215"above_region_count": lambda value: db.select(db.func.count(PictureRegion.id)).where(PictureRegion.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() >= value, 1216"below_region_count": lambda value: db.select(db.func.count(PictureRegion.id)).where(PictureRegion.resource_id == PictureResource.id).correlate(PictureResource).scalar_subquery() <= value, 1217"copied_from": lambda value: PictureResource.copied_from_id.in_(value), 1218} 1219 1220if "want" in query_data: 1221for i in query_data["want"]: 1222if len(i) != 1: 1223raise APIError(400, "Each requirement must have exactly one key") 1224requirement, value = list(i.items())[0] 1225if requirement not in requirement_conditions: 1226raise APIError(400, f"Unknown requirement type: {requirement}") 1227 1228condition = requirement_conditions[requirement] 1229query = query.filter(condition(value)) 1230if "exclude" in query_data: 1231for i in query_data["exclude"]: 1232if len(i) != 1: 1233raise APIError(400, "Each exclusion must have exactly one key") 1234requirement, value = list(i.items())[0] 1235if requirement not in requirement_conditions: 1236raise APIError(400, f"Unknown requirement type: {requirement}") 1237 1238condition = requirement_conditions[requirement] 1239query = query.filter(~condition(value)) 1240if not query_data.get("include_obsolete", False): 1241query = query.filter(PictureResource.replaced_by_id.is_(None)) 1242 1243return query 1244 1245 1246@app.route("/query-pictures") 1247def graphical_query_pictures(): 1248return flask.render_template("graphical-query-pictures.html") 1249 1250 1251@app.route("/query-pictures-results") 1252def graphical_query_pictures_results(): 1253query_yaml = flask.request.args.get("query", "") 1254yaml_parser = yaml.YAML() 1255query_data = yaml_parser.load(query_yaml) or {} 1256try: 1257query = get_picture_query(query_data) 1258except APIError as e: 1259flask.abort(e.status_code) 1260 1261page = int(flask.request.args.get("page", 1)) 1262per_page = int(flask.request.args.get("per_page", 16)) 1263 1264resources = query.paginate(page=page, per_page=per_page) 1265 1266return flask.render_template("graphical-query-pictures-results.html", resources=resources, 1267query=query_yaml, 1268page_number=page, page_length=per_page, 1269num_pages=resources.pages, 1270prev_page=resources.prev_num, next_page=resources.next_num) 1271 1272 1273@app.route("/raw/picture/<int:id>") 1274def raw_picture(id): 1275resource = db.session.get(PictureResource, id) 1276if resource is None: 1277flask.abort(404) 1278 1279response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), 1280str(resource.id)) 1281response.mimetype = resource.file_format 1282 1283return response 1284 1285 1286@app.route("/object/") 1287def graphical_object_types(): 1288return flask.render_template("object-types.html", objects=PictureObject.query.all()) 1289 1290 1291@app.route("/api/object-types") 1292def object_types(): 1293objects = db.session.query(PictureObject).all() 1294return flask.jsonify({object.id: object.description for object in objects}) 1295 1296 1297@app.route("/api/query-pictures", methods=["POST"]) # sadly GET can't have a body 1298def query_pictures(): 1299offset = int(flask.request.args.get("offset", 0)) 1300limit = int(flask.request.args.get("limit", 16)) 1301ordering = flask.request.args.get("ordering", "date-desc") 1302 1303yaml_parser = yaml.YAML() 1304query_data = yaml_parser.load(flask.request.data) or {} 1305try: 1306query = get_picture_query(query_data) 1307except APIError as e: 1308return flask.jsonify({"error": e.message}), e.status_code 1309 1310rating_count_subquery = db.select(db.func.count(PictureRating.id)).where( 1311PictureRating.resource_id == PictureResource.id).scalar_subquery() 1312region_count_subquery = db.select(db.func.count(PictureRegion.id)).where( 1313PictureRegion.resource_id == PictureResource.id).scalar_subquery() 1314rating_subquery = db.select(db.func.coalesce(db.func.avg(PictureRating.rating), 0)).where( 1315PictureRating.resource_id == PictureResource.id).scalar_subquery() 1316 1317match ordering: 1318case "date-desc": 1319query = query.order_by(PictureResource.timestamp.desc()) 1320case "date-asc": 1321query = query.order_by(PictureResource.timestamp.asc()) 1322case "title-asc": 1323query = query.order_by(PictureResource.title.asc()) 1324case "title-desc": 1325query = query.order_by(PictureResource.title.desc()) 1326case "random": 1327query = query.order_by(db.func.random()) 1328case "number-regions-desc": 1329query = query.order_by(region_count_subquery.desc()) 1330case "number-regions-asc": 1331query = query.order_by(region_count_subquery.asc()) 1332case "rating-desc": 1333query = query.order_by(rating_subquery.desc()) 1334case "rating-asc": 1335query = query.order_by(rating_subquery.asc()) 1336case "number-ratings-desc": 1337query = query.order_by(rating_count_subquery.desc()) 1338case "number-ratings-asc": 1339query = query.order_by(rating_count_subquery.asc()) 1340 1341query = query.offset(offset).limit(limit) 1342resources = query.all() 1343 1344json_response = { 1345"date_generated": datetime.utcnow().timestamp(), 1346"resources": [], 1347"offset": offset, 1348"limit": limit, 1349} 1350 1351json_resources = json_response["resources"] 1352 1353for resource in resources: 1354json_resource = { 1355"id": resource.id, 1356"title": resource.title, 1357"description": resource.description, 1358"timestamp": resource.timestamp.timestamp(), 1359"origin_url": resource.origin_url, 1360"author": resource.author_name, 1361"file_format": resource.file_format, 1362"width": resource.width, 1363"height": resource.height, 1364"nature": resource.nature_id, 1365"licences": [licence.licence_id for licence in resource.licences], 1366"replaces": resource.replaces_id, 1367"replaced_by": resource.replaced_by_id, 1368"regions": [], 1369"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1370} 1371for region in resource.regions: 1372json_resource["regions"].append({ 1373"object": region.object_id, 1374"type": region.json["type"], 1375"shape": region.json["shape"], 1376}) 1377 1378json_resources.append(json_resource) 1379 1380return flask.jsonify(json_response) 1381 1382 1383@app.route("/api/picture/<int:id>/") 1384def api_picture(id): 1385resource = db.session.get(PictureResource, id) 1386if resource is None: 1387flask.abort(404) 1388 1389json_resource = { 1390"id": resource.id, 1391"title": resource.title, 1392"description": resource.description, 1393"timestamp": resource.timestamp.timestamp(), 1394"origin_url": resource.origin_url, 1395"author": resource.author_name, 1396"file_format": resource.file_format, 1397"width": resource.width, 1398"height": resource.height, 1399"nature": resource.nature_id, 1400"licences": [licence.licence_id for licence in resource.licences], 1401"replaces": resource.replaces_id, 1402"replaced_by": resource.replaced_by_id, 1403"regions": [], 1404"download": config.ROOT_URL + flask.url_for("raw_picture", id=resource.id), 1405"rating_average": resource.average_rating, 1406"rating_count": resource.rating_totals, 1407} 1408for region in resource.regions: 1409json_resource["regions"].append({ 1410"object": region.object_id, 1411"type": region.json["type"], 1412"shape": region.json["shape"], 1413}) 1414 1415return flask.jsonify(json_resource) 1416 1417 1418@app.route("/api/licence/") 1419def api_licences(): 1420licences = db.session.query(Licence).all() 1421json_licences = { 1422licence.id: { 1423"title": licence.title, 1424"free": licence.free, 1425"pinned": licence.pinned, 1426} for licence in licences 1427} 1428 1429return flask.jsonify(json_licences) 1430 1431 1432@app.route("/api/licence/<id>/") 1433def api_licence(id): 1434licence = db.session.get(Licence, id) 1435if licence is None: 1436flask.abort(404) 1437 1438json_licence = { 1439"id": licence.id, 1440"title": licence.title, 1441"description": licence.description, 1442"info_url": licence.info_url, 1443"legalese_url": licence.url, 1444"free": licence.free, 1445"logo_url": licence.logo_url, 1446"pinned": licence.pinned, 1447} 1448 1449return flask.jsonify(json_licence) 1450 1451 1452@app.route("/api/nature/") 1453def api_natures(): 1454natures = db.session.query(PictureNature).all() 1455json_natures = { 1456nature.id: nature.description for nature in natures 1457} 1458 1459return flask.jsonify(json_natures) 1460 1461 1462@app.route("/api/user/") 1463def api_users(): 1464offset = int(flask.request.args.get("offset", 0)) 1465limit = int(flask.request.args.get("limit", 16)) 1466 1467users = db.session.query(User).offset(offset).limit(limit).all() 1468 1469json_users = { 1470user.username: { 1471"admin": user.admin, 1472} for user in users 1473} 1474 1475return flask.jsonify(json_users) 1476 1477 1478@app.route("/api/user/<username>/") 1479def api_user(username): 1480user = db.session.get(User, username) 1481if user is None: 1482flask.abort(404) 1483 1484json_user = { 1485"username": user.username, 1486"admin": user.admin, 1487"joined": user.joined_timestamp.timestamp(), 1488} 1489 1490return flask.jsonify(json_user) 1491 1492 1493@app.route("/api/login", methods=["POST"]) 1494def api_login(): 1495username = flask.request.json["username"] 1496password = flask.request.json["password"] 1497 1498user = db.session.get(User, username) 1499 1500if user is None: 1501return flask.jsonify({"error": "This username is not registered. To prevent spam, you must use the HTML interface to register."}), 401 1502 1503if not bcrypt.check_password_hash(user.password_hashed, password): 1504return flask.jsonify({"error": "Incorrect password"}), 401 1505 1506flask.session["username"] = username 1507 1508return flask.jsonify({"message": "You have been logged in. Your HTTP client must support cookies to use features of this API that require authentication."}) 1509 1510 1511@app.route("/api/logout", methods=["POST"]) 1512def api_logout(): 1513flask.session.pop("username", None) 1514return flask.jsonify({"message": "You have been logged out."}) 1515 1516 1517@app.route("/api/upload", methods=["POST"]) 1518def api_upload(): 1519if "username" not in flask.session: 1520return flask.jsonify({"error": "You must be logged in to upload pictures"}), 401 1521 1522json_ = json.loads(flask.request.form["json"]) 1523title = json_["title"] 1524description = json_.get("description", "") 1525origin_url = json_.get("origin_url", "") 1526author = db.session.get(User, flask.session["username"]) 1527licence_ids = json_["licence"] 1528nature_id = json_["nature"] 1529file = flask.request.files["file"] 1530 1531if not file or not file.filename: 1532return flask.jsonify({"error": "An image file must be uploaded"}), 400 1533 1534if not file.mimetype.startswith("image/") or file.mimetype == "image/svg+xml": 1535return flask.jsonify({"error": "Only bitmap images are supported"}), 400 1536 1537if not title: 1538return flask.jsonify({"error": "Give a title"}), 400 1539 1540if not description: 1541description = "" 1542 1543if not nature_id: 1544return flask.jsonify({"error": "Give a picture type"}), 400 1545 1546if not licence_ids: 1547return flask.jsonify({"error": "Give licences"}), 400 1548 1549licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1550if not any(licence.free for licence in licences): 1551return flask.jsonify({"error": "Use at least one free licence"}), 400 1552 1553resource = PictureResource(title, author, description, origin_url, licence_ids, 1554file.mimetype, 1555db.session.get(PictureNature, nature_id)) 1556db.session.add(resource) 1557db.session.commit() 1558file.save(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1559pil_image = Image.open(path.join(config.DATA_PATH, "pictures", str(resource.id))) 1560resource.width, resource.height = pil_image.size 1561db.session.commit() 1562 1563if json_.get("annotations"): 1564try: 1565resource.put_annotations(json_["annotations"]) 1566db.session.commit() 1567except json.JSONDecodeError: 1568return flask.jsonify({"error": "Invalid annotations"}), 400 1569 1570return flask.jsonify({"message": "Picture uploaded successfully", "id": resource.id}) 1571 1572 1573@app.route("/api/picture/<int:id>/update", methods=["POST"]) 1574def api_update_picture(id): 1575resource = db.session.get(PictureResource, id) 1576if resource is None: 1577return flask.jsonify({"error": "Picture not found"}), 404 1578current_user = db.session.get(User, flask.session.get("username")) 1579if current_user is None: 1580return flask.jsonify({"error": "You must be logged in to edit pictures"}), 401 1581if resource.author != current_user and not current_user.admin: 1582return flask.jsonify({"error": "You are not the author of this picture"}), 403 1583 1584title = flask.request.json.get("title", resource.title) 1585description = flask.request.json.get("description", resource.description) 1586origin_url = flask.request.json.get("origin_url", resource.origin_url) 1587licence_ids = flask.request.json.get("licence", [licence.licence_id for licence in resource.licences]) 1588nature_id = flask.request.json.get("nature", resource.nature_id) 1589 1590if not title: 1591return flask.jsonify({"error": "Give a title"}), 400 1592 1593if not description: 1594description = "" 1595 1596if not nature_id: 1597return flask.jsonify({"error": "Give a picture type"}), 400 1598 1599if not licence_ids: 1600return flask.jsonify({"error": "Give licences"}), 400 1601 1602licences = [db.session.get(Licence, licence_id) for licence_id in licence_ids] 1603 1604if not any(licence.free for licence in licences): 1605return flask.jsonify({"error": "Use at least one free licence"}), 400 1606 1607resource.title = title 1608resource.description = description 1609resource.origin_url = origin_url 1610resource.licences = licences 1611resource.nature = db.session.get(PictureNature, nature_id) 1612 1613db.session.commit() 1614 1615return flask.jsonify({"message": "Picture updated successfully"}) 1616 1617 1618@app.route("/api/picture/<int:id>/rate", methods=["POST"]) 1619def api_rate_picture(id): 1620resource = db.session.get(PictureResource, id) 1621if resource is None: 1622flask.abort(404) 1623 1624current_user = db.session.get(User, flask.session.get("username")) 1625if current_user is None: 1626flask.abort(401) 1627 1628rating = int(flask.request.json.get("rating", 0)) 1629 1630if not rating: 1631# Delete the existing rating 1632if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 1633db.session.delete(PictureRating.query.filter_by(resource=resource, 1634user=current_user).first()) 1635db.session.commit() 1636 1637return flask.jsonify({"message": "Existing rating removed"}) 1638 1639if not 1 <= rating <= 5: 1640flask.flash("Invalid rating") 1641return flask.jsonify({"error": "Invalid rating"}), 400 1642 1643if PictureRating.query.filter_by(resource=resource, user=current_user).first(): 1644PictureRating.query.filter_by(resource=resource, user=current_user).first().rating = rating 1645else: 1646# Create a new rating 1647db.session.add(PictureRating(resource, current_user, rating)) 1648 1649db.session.commit() 1650 1651return flask.jsonify({"message": "Rating saved"}) 1652 1653 1654@app.route("/api/gallery/<int:id>/") 1655def api_gallery(id): 1656gallery = db.session.get(Gallery, id) 1657if gallery is None: 1658flask.abort(404) 1659 1660json_gallery = { 1661"id": gallery.id, 1662"title": gallery.title, 1663"description": gallery.description, 1664"owner": gallery.owner_name, 1665"users": [user.username for user in gallery.users], 1666} 1667 1668return flask.jsonify(json_gallery) 1669 1670 1671@app.route("/api/gallery/<int:id>/edit", methods=["POST"]) 1672def api_edit_gallery(id): 1673gallery = db.session.get(Gallery, id) 1674if gallery is None: 1675flask.abort(404) 1676 1677current_user = db.session.get(User, flask.session.get("username")) 1678if current_user is None: 1679flask.abort(401) 1680 1681if current_user != gallery.owner and not current_user.admin: 1682flask.abort(403) 1683 1684title = flask.request.json.get("title", gallery.title) 1685description = flask.request.json.get("description", gallery.description) 1686 1687if not title: 1688return flask.jsonify({"error": "Give a title"}), 400 1689 1690if not description: 1691description = "" 1692 1693gallery.title = title 1694gallery.description = description 1695 1696db.session.commit() 1697 1698return flask.jsonify({"message": "Gallery updated successfully"}) 1699 1700 1701@app.route("/api/new-gallery", methods=["POST"]) 1702def api_new_gallery(): 1703if "username" not in flask.session: 1704return flask.jsonify({"error": "You must be logged in to create galleries"}), 401 1705 1706title = flask.request.json.get("title") 1707description = flask.request.json.get("description", "") 1708 1709if not title: 1710return flask.jsonify({"error": "Give a title"}), 400 1711 1712gallery = Gallery(title, description, db.session.get(User, flask.session["username"])) 1713db.session.add(gallery) 1714db.session.commit() 1715 1716return flask.jsonify({"message": "Gallery created successfully", "id": gallery.id}) 1717 1718 1719@app.route("/api/gallery/<int:id>/add-picture", methods=["POST"]) 1720def api_gallery_add_picture(id): 1721gallery = db.session.get(Gallery, id) 1722if gallery is None: 1723flask.abort(404) 1724 1725if "username" not in flask.session: 1726return flask.jsonify({"error": "You must be logged in to add pictures to galleries"}), 401 1727 1728current_user = db.session.get(User, flask.session.get("username")) 1729 1730if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1731return flask.jsonify({"error": "You do not have permission to add pictures to this gallery"}), 403 1732 1733picture_id = flask.request.json.get("picture_id") 1734 1735try: 1736picture_id = int(picture_id) 1737except ValueError: 1738return flask.jsonify({"error": "Invalid picture ID"}), 400 1739 1740picture = db.session.get(PictureResource, picture_id) 1741if picture is None: 1742return flask.jsonify({"error": "The picture doesn't exist"}), 404 1743 1744if PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first(): 1745return flask.jsonify({"error": "This picture is already in the gallery"}), 400 1746 1747db.session.add(PictureInGallery(picture, gallery)) 1748 1749db.session.commit() 1750 1751return flask.jsonify({"message": "Picture added to gallery"}) 1752 1753 1754@app.route("/api/gallery/<int:id>/remove-picture", methods=["POST"]) 1755def api_gallery_remove_picture(id): 1756gallery = db.session.get(Gallery, id) 1757if gallery is None: 1758flask.abort(404) 1759 1760if "username" not in flask.session: 1761return flask.jsonify({"error": "You must be logged in to remove pictures from galleries"}), 401 1762 1763current_user = db.session.get(User, flask.session.get("username")) 1764 1765if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first(): 1766return flask.jsonify({"error": "You do not have permission to remove pictures from this gallery"}), 403 1767 1768picture_id = flask.request.json.get("picture_id") 1769 1770try: 1771picture_id = int(picture_id) 1772except ValueError: 1773return flask.jsonify({"error": "Invalid picture ID"}), 400 1774 1775picture = db.session.get(PictureResource, picture_id) 1776if picture is None: 1777return flask.jsonify({"error": "The picture doesn't exist"}), 404 1778 1779picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first() 1780if picture_in_gallery is None: 1781return flask.jsonify({"error": "This picture isn't in the gallery"}), 400 1782 1783db.session.delete(picture_in_gallery) 1784 1785db.session.commit() 1786 1787return flask.jsonify({"message": "Picture removed from gallery"}) 1788 1789 1790@app.route("/api/gallery/<int:id>/users/add", methods=["POST"]) 1791def api_gallery_add_user(id): 1792gallery = db.session.get(Gallery, id) 1793if gallery is None: 1794flask.abort(404) 1795 1796current_user = db.session.get(User, flask.session.get("username")) 1797if current_user is None: 1798flask.abort(401) 1799 1800if current_user != gallery.owner and not current_user.admin: 1801flask.abort(403) 1802 1803username = flask.request.json.get("username") 1804if username == gallery.owner_name: 1805return flask.jsonify({"error": "The owner cannot be added to trusted users"}), 400 1806 1807user = db.session.get(User, username) 1808if user is None: 1809return flask.jsonify({"error": "User not found"}), 404 1810 1811if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): 1812return flask.jsonify({"error": "User is already in the gallery"}), 400 1813 1814db.session.add(UserInGallery(user, gallery)) 1815 1816db.session.commit() 1817 1818return flask.jsonify({"message": "User added to gallery"}) 1819 1820 1821@app.route("/api/gallery/<int:id>/users/remove", methods=["POST"]) 1822def api_gallery_remove_user(id): 1823gallery = db.session.get(Gallery, id) 1824if gallery is None: 1825flask.abort(404) 1826 1827current_user = db.session.get(User, flask.session.get("username")) 1828if current_user is None: 1829flask.abort(401) 1830 1831if current_user != gallery.owner and not current_user.admin: 1832flask.abort(403) 1833 1834username = flask.request.json.get("username") 1835user = db.session.get(User, username) 1836if user is None: 1837return flask.jsonify({"error": "User not found"}), 404 1838 1839user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() 1840if user_in_gallery is None: 1841return flask.jsonify({"error": "User is not in the gallery"}), 400 1842 1843db.session.delete(user_in_gallery) 1844 1845db.session.commit() 1846 1847return flask.jsonify({"message": "User removed from gallery"}) 1848 1849