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_user
import 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 generators
value = list(value) # prevent generators
return 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 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 information
id = 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 information
pictures = db.relationship("PictureLicence", back_populates="licence")
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):
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 domain
origin_url = db.Column(db.String(2048),
nullable=True) # should be left empty if it's original or the source is unknown but public domain
class 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 type
file_format = db.Column(db.String(64), nullable=False) # MIME type
width = 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 URLs
if "/" in picture_id: # also allow full URLs
picture_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 body
def 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">