roundabout,
created on Monday, 9 September 2024, 15:13:35 (1725894815),
received on Tuesday, 10 September 2024, 13:58:09 (1725976689)
Author identity: vlad <vlad.muntoiu@gmail.com>
8b8e504430c0708e47471423a09b551ceaf9e904
app.py
@@ -20,19 +20,17 @@ import ruamel.yaml as yaml
from PIL import Image from sqlalchemy.orm.persistence import post_update from sqlalchemy.sql.functions import current_userimport config import markdown app = flask.Flask(__name__) bcrypt = Bcrypt(app) app.config["SQLALCHEMY_DATABASE_URI"] = config.DB_URI app.config["SECRET_KEY"] = config.DB_PASSWORD db = SQLAlchemy(app) migrate = Migrate(app, db)
@@ -44,7 +42,7 @@ def split(value, separator=None, maxsplit=-1):
@app.template_filter("median") def median(value): value = list(value) # prevent generatorsvalue = list(value) # prevent generatorsreturn sorted(value)[len(value) // 2]
@@ -77,6 +75,7 @@ with app.app_context():
pictures = db.relationship("PictureResource", back_populates="author") joined_timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) galleries = db.relationship("Gallery", back_populates="owner") galleries_joined = db.relationship("UserInGallery", back_populates="user")def __init__(self, username, password): self.username = username
@@ -90,17 +89,23 @@ with app.app_context():
class Licence(db.Model): id = db.Column(db.String(64), primary_key=True) # SPDX identifiertitle = db.Column(db.UnicodeText, nullable=False) # the official name of the licencedescription = db.Column(db.UnicodeText, nullable=False) # brief description of its permissions and restrictionsinfo_url = db.Column(db.String(1024), nullable=False) # the URL to a page with general information about the licenceurl = db.Column(db.String(1024), nullable=True) # the URL to a page with the full text of the licence and more informationid = db.Column(db.String(64), primary_key=True) # SPDX identifier title = db.Column(db.UnicodeText, nullable=False) # the official name of the licence description = db.Column(db.UnicodeText, nullable=False) # brief description of its permissions and restrictions info_url = db.Column(db.String(1024), nullable=False) # the URL to a page with general information about the licence url = db.Column(db.String(1024), nullable=True) # the URL to a page with the full text of the licence and more informationpictures = db.relationship("PictureLicence", back_populates="licence") free = db.Column(db.Boolean, nullable=False, default=False) # whether the licence is free or notlogo_url = db.Column(db.String(1024), nullable=True) # URL to the logo of the licencepinned = db.Column(db.Boolean, nullable=False, default=False) # whether the licence should be shown at the top of the listdef __init__(self, id, title, description, info_url, url, free, logo_url=None, pinned=False):free = db.Column(db.Boolean, nullable=False, default=False) # whether the licence is free or not logo_url = db.Column(db.String(1024), nullable=True) # URL to the logo of the licence pinned = db.Column(db.Boolean, nullable=False, default=False) # whether the licence should be shown at the top of the list def __init__(self, id, title, description, info_url, url, free, logo_url=None, pinned=False):self.id = id self.title = title self.description = description
@@ -132,7 +137,8 @@ with app.app_context():
title = db.Column(db.UnicodeText, nullable=False) description = db.Column(db.UnicodeText, nullable=False) timestamp = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) origin_url = db.Column(db.String(2048), nullable=True) # should be left empty if it's original or the source is unknown but public domainorigin_url = db.Column(db.String(2048), nullable=True) # should be left empty if it's original or the source is unknown but public domainclass PictureNature(db.Model):
@@ -186,7 +192,8 @@ with app.app_context():
id = db.Column(db.Integer, primary_key=True, autoincrement=True) json = db.Column(sqlalchemy.dialects.postgresql.JSONB, nullable=False) resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=False)resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=False)object_id = db.Column(db.String(64), db.ForeignKey("picture_object.id"), nullable=True) resource = db.relationship("PictureResource", backref="regions")
@@ -201,7 +208,7 @@ with app.app_context():
class PictureResource(Resource): # This is only for bitmap pictures. Vectors will be stored under a different model # File name is the ID in the picture directory under data, without an extension file_format = db.Column(db.String(64), nullable=False) # MIME typefile_format = db.Column(db.String(64), nullable=False) # MIME typewidth = db.Column(db.Integer, nullable=False) height = db.Column(db.Integer, nullable=False) nature_id = db.Column(db.String(32), db.ForeignKey("picture_nature.id"), nullable=True)
@@ -220,14 +227,16 @@ with app.app_context():
replaced_by = db.relationship("PictureResource", remote_side="PictureResource.id", foreign_keys=[replaced_by_id], post_update=True) copied_from_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=True)copied_from_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=True)copied_from = db.relationship("PictureResource", remote_side="PictureResource.id", backref="copies", foreign_keys=[copied_from_id]) licences = db.relationship("PictureLicence", back_populates="resource") galleries = db.relationship("PictureInGallery", back_populates="resource") def __init__(self, title, author, description, origin_url, licence_ids, mime, nature=None):def __init__(self, title, author, description, origin_url, licence_ids, mime, nature=None):self.title = title self.author = author self.description = description
@@ -260,7 +269,8 @@ with app.app_context():
class PictureInGallery(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True) resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=False)resource_id = db.Column(db.Integer, db.ForeignKey("picture_resource.id"), nullable=False)gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) resource = db.relationship("PictureResource")
@@ -271,6 +281,19 @@ with app.app_context():
self.gallery = gallery class UserInGallery(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True) username = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) gallery_id = db.Column(db.Integer, db.ForeignKey("gallery.id"), nullable=False) user = db.relationship("User") gallery = db.relationship("Gallery") def __init__(self, user, gallery): self.user = user self.gallery = gallery class Gallery(db.Model): id = db.Column(db.Integer, primary_key=True, autoincrement=True) title = db.Column(db.UnicodeText, nullable=False)
@@ -278,6 +301,7 @@ with app.app_context():
pictures = db.relationship("PictureInGallery", back_populates="gallery") owner_name = db.Column(db.String(32), db.ForeignKey("user.username"), nullable=False) owner = db.relationship("User", back_populates="galleries") users = db.relationship("UserInGallery", back_populates="gallery")def __init__(self, title, description, owner): self.title = title
@@ -285,10 +309,10 @@ with app.app_context():
self.owner = owner @app.route("/") def index(): return flask.render_template("home.html", resources=PictureResource.query.order_by(db.func.random()).limit(10).all())return flask.render_template("home.html", resources=PictureResource.query.order_by( db.func.random()).limit(10).all())@app.route("/accounts/")
@@ -334,7 +358,8 @@ def signup():
return flask.redirect("/accounts") if set(username) > set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"): flask.flash("Usernames can only contain the Latin alphabet, digits, hyphens, and underscores.")flask.flash( "Usernames can only contain the Latin alphabet, digits, hyphens, and underscores.")return flask.redirect("/accounts") if len(username) < 3 or len(username) > 32:
@@ -379,15 +404,18 @@ def has_object(id):
if object_ is None: flask.abort(404) query = db.session.query(PictureResource).join(PictureRegion).filter(PictureRegion.object_id == id)query = db.session.query(PictureResource).join(PictureRegion).filter( PictureRegion.object_id == id)page = int(flask.request.args.get("page", 1)) per_page = int(flask.request.args.get("per_page", 16)) resources = query.paginate(page=page, per_page=per_page) return flask.render_template("object.html", object=object_, resources=resources, page_number=page,page_length=per_page, num_pages=resources.pages, prev_page=resources.prev_num,return flask.render_template("object.html", object=object_, resources=resources, page_number=page, page_length=per_page, num_pages=resources.pages, prev_page=resources.prev_num,next_page=resources.next_num, PictureRegion=PictureRegion)
@@ -397,7 +425,8 @@ def upload():
flask.flash("Log in to upload pictures.") return flask.redirect("/accounts") licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), Licence.title).all()licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), Licence.title).all()types = PictureNature.query.all()
@@ -446,7 +475,8 @@ def upload_post():
flask.flash("Select at least one free licence") return flask.redirect(flask.request.url) resource = PictureResource(title, author, description, origin_url, licence_ids, file.mimetype,resource = PictureResource(title, author, description, origin_url, licence_ids, file.mimetype,db.session.get(PictureNature, nature_id)) db.session.add(resource) db.session.commit()
@@ -480,8 +510,8 @@ def picture(id):
return flask.render_template("picture.html", resource=resource, file_extension=mimetypes.guess_extension(resource.file_format), size=image.size, copies=resource.copies, have_permission=have_permission)size=image.size, copies=resource.copies, have_permission=have_permission)@app.route("/picture/<int:id>/annotate")
@@ -536,7 +566,6 @@ def put_annotations_form_post(id):
return flask.redirect("/picture/" + str(resource.id)) @app.route("/picture/<int:id>/save-annotations", methods=["POST"]) def save_annotations(id): resource = db.session.get(PictureResource, id)
@@ -660,11 +689,13 @@ def edit_picture(id):
if resource.author != current_user and not current_user.admin: flask.abort(403) licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), Licence.title).all()licences = Licence.query.order_by(Licence.free.desc(), Licence.pinned.desc(), Licence.title).all()types = PictureNature.query.all() return flask.render_template("edit-picture.html", resource=resource, licences=licences, types=types,return flask.render_template("edit-picture.html", resource=resource, licences=licences, types=types,PictureLicence=PictureLicence)
@@ -730,8 +761,10 @@ def copy_picture(id):
if current_user is None: flask.abort(401) new_resource = PictureResource(resource.title, current_user, resource.description, resource.origin_url,[licence.licence_id for licence in resource.licences], resource.file_format,new_resource = PictureResource(resource.title, current_user, resource.description, resource.origin_url, [licence.licence_id for licence in resource.licences], resource.file_format,resource.nature) for region in resource.regions:
@@ -761,9 +794,10 @@ def gallery(id):
current_user = db.session.get(User, flask.session.get("username")) have_permission = current_user and (current_user == gallery.owner or current_user.admin)have_permission = current_user and (current_user == gallery.owner or current_user.admin or UserInGallery.query.filter_by(user=current_user, gallery=gallery).first())return flask.render_template("gallery.html", gallery=gallery, have_permission=have_permission)return flask.render_template("gallery.html", gallery=gallery, have_permission=have_permission)@app.route("/create-gallery")
@@ -786,7 +820,8 @@ def create_gallery_post():
description = flask.request.form.get("description", "") gallery = Gallery(flask.request.form["title"], description, db.session.get(User, flask.session["username"]))gallery = Gallery(flask.request.form["title"], description, db.session.get(User, flask.session["username"]))db.session.add(gallery) db.session.commit()
@@ -802,11 +837,11 @@ def gallery_add_picture(id):
if "username" not in flask.session: flask.abort(401) if flask.session["username"] != gallery.owner_name and not db.session.get(User, flask.session["username"]).admin:if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first():flask.abort(403) picture_id = flask.request.form.get("picture_id") if "/" in picture_id: # also allow full URLsif "/" in picture_id: # also allow full URLspicture_id = picture_id.rstrip("/").rpartition("/")[1] if not picture_id: flask.flash("Select a picture")
@@ -838,7 +873,9 @@ def gallery_remove_picture(id):
if "username" not in flask.session: flask.abort(401) if flask.session["username"] != gallery.owner_name and not db.session.get(User, flask.session["username"]).admin:current_user = db.session.get(User, flask.session.get("username")) if flask.session["username"] != gallery.owner_name and not current_user.admin and not UserInGallery.query.filter_by(user=current_user, gallery=gallery).first():flask.abort(403) picture_id = int(flask.request.form.get("picture_id"))
@@ -848,7 +885,8 @@ def gallery_remove_picture(id):
flask.flash("Invalid picture") return flask.redirect("/gallery/" + str(gallery.id)) picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first()picture_in_gallery = PictureInGallery.query.filter_by(resource=picture, gallery=gallery).first()if picture_in_gallery is None: flask.flash("This picture isn't in the gallery") return flask.redirect("/gallery/" + str(gallery.id))
@@ -860,15 +898,93 @@ def gallery_remove_picture(id):
return flask.redirect("/gallery/" + str(gallery.id)) @app.route("/gallery/<int:id>/users") def gallery_users(id): gallery = db.session.get(Gallery, id) if gallery is None: flask.abort(404) current_user = db.session.get(User, flask.session.get("username")) have_permission = current_user and (current_user == gallery.owner or current_user.admin) return flask.render_template("gallery-users.html", gallery=gallery, have_permission=have_permission) @app.route("/gallery/<int:id>/users/add", methods=["POST"]) def gallery_add_user(id): gallery = db.session.get(Gallery, id) if gallery is None: flask.abort(404) current_user = db.session.get(User, flask.session.get("username")) if current_user is None: flask.abort(401) if current_user != gallery.owner and not current_user.admin: flask.abort(403) username = flask.request.form.get("username") if username == gallery.owner_name: flask.flash("The owner is already in the gallery") return flask.redirect("/gallery/" + str(gallery.id) + "/users") user = db.session.get(User, username) if user is None: flask.flash("User not found") return flask.redirect("/gallery/" + str(gallery.id) + "/users") if UserInGallery.query.filter_by(user=user, gallery=gallery).first(): flask.flash("User is already in the gallery") return flask.redirect("/gallery/" + str(gallery.id) + "/users") db.session.add(UserInGallery(user, gallery)) db.session.commit() return flask.redirect("/gallery/" + str(gallery.id) + "/users") @app.route("/gallery/<int:id>/users/remove", methods=["POST"]) def gallery_remove_user(id): gallery = db.session.get(Gallery, id) if gallery is None: flask.abort(404) current_user = db.session.get(User, flask.session.get("username")) if current_user is None: flask.abort(401) if current_user != gallery.owner and not current_user.admin: flask.abort(403) username = flask.request.form.get("username") user = db.session.get(User, username) if user is None: flask.flash("User not found") return flask.redirect("/gallery/" + str(gallery.id) + "/users") user_in_gallery = UserInGallery.query.filter_by(user=user, gallery=gallery).first() if user_in_gallery is None: flask.flash("User is not in the gallery") return flask.redirect("/gallery/" + str(gallery.id) + "/users") db.session.delete(user_in_gallery) db.session.commit() return flask.redirect("/gallery/" + str(gallery.id) + "/users") def get_picture_query(query_data): query = db.session.query(PictureResource) requirement_conditions = { "has_object": lambda value: PictureResource.regions.any( PictureRegion.object_id.in_(value)),PictureRegion.object_id.in_(value)),"nature": lambda value: PictureResource.nature_id.in_(value), "licence": lambda value: PictureResource.licences.any( PictureLicence.licence_id.in_(value)),PictureLicence.licence_id.in_(value)),"author": lambda value: PictureResource.author_name.in_(value), "title": lambda value: PictureResource.title.ilike(value), "description": lambda value: PictureResource.description.ilike(value),
@@ -881,9 +997,9 @@ def get_picture_query(query_data):
"above_height": lambda value: PictureResource.height >= value, "below_height": lambda value: PictureResource.height <= value, "before_date": lambda value: PictureResource.timestamp <= datetime.utcfromtimestamp( value),value),"after_date": lambda value: PictureResource.timestamp >= datetime.utcfromtimestamp( value)value)} if "want" in query_data: for i in query_data["want"]:
@@ -920,9 +1036,11 @@ def graphical_query_pictures_results():
resources = query.paginate(page=page, per_page=per_page) return flask.render_template("graphical-query-pictures-results.html", resources=resources, query=query_yaml,page_number=page, page_length=per_page, num_pages=resources.pages,prev_page=resources.prev_num, next_page=resources.next_num)return flask.render_template("graphical-query-pictures-results.html", resources=resources, query=query_yaml, page_number=page, page_length=per_page, num_pages=resources.pages, prev_page=resources.prev_num, next_page=resources.next_num)@app.route("/raw/picture/<int:id>")
@@ -931,7 +1049,8 @@ def raw_picture(id):
if resource is None: flask.abort(404) response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), str(resource.id))response = flask.send_from_directory(path.join(config.DATA_PATH, "pictures"), str(resource.id))response.mimetype = resource.file_format return response
@@ -948,7 +1067,7 @@ def object_types():
return flask.jsonify({object.id: object.description for object in objects}) @app.route("/api/query-pictures", methods=["POST"]) # sadly GET can't have a body@app.route("/api/query-pictures", methods=["POST"]) # sadly GET can't have a bodydef query_pictures(): offset = int(flask.request.args.get("offset", 0)) limit = int(flask.request.args.get("limit", 16))
@@ -1122,4 +1241,3 @@ def api_user(username):
} return flask.jsonify(json_user)
templates/gallery-users.html
@@ -0,0 +1,26 @@
{% extends "default.html" %} {% block title %}Users of {{ gallery.title }} | gigadata{% endblock %} {% block content %} <x-frame style="--width: 768px" class="vbox"> <h1>Users: {{ gallery.title }}</h1> <p>{{ gallery.description }}</p> {% if have_permission %} <form class="buttonbox" method="POST" action="/gallery/{{ gallery.id }}/users/add"> <input name="username" type="text" placeholder="Username" required aria-label="Username"> <button type="submit">Add user</button> </form> <form class="buttonbox" method="POST" action="/gallery/{{ gallery.id }}/users/remove"> <input name="username" type="text" placeholder="Username" required aria-label="Username"> <button type="submit">Remove user</button> </form> {% endif %} <p>Besides the owner, these users can also add and remove resources from this gallery:</p> <ul> {% for user in gallery.users %} <li><a href="/user/{{ user.user.username }}">{{ user.user.formatted_name }}</a></li> {% endfor %} </ul> </x-frame> {% endblock %}
templates/gallery.html
@@ -6,6 +6,9 @@
<x-frame style="--width: 768px" class="vbox"> <h1>{{ gallery.title }}</h1> <p>{{ gallery.description }}</p> <p> <a href="/gallery/{{ gallery.id }}/users">Users</a> </p>{% if have_permission %} <form class="buttonbox" method="POST" action="/gallery/{{ gallery.id }}/add-picture"> <input name="picture_id" type="text" placeholder="Picture ID" required aria-label="Picture ID">