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