Web platform for sharing free image data for ML and research

Homepage: https://datasets.roundabout-host.com

Important information: Google announced that, from September 2026, Android devices will require ALL apps to be signed by Google, effectively leading to an iOS situation. Value your right to a computer that does what you want; do not tolerate this monopolistic practice! Contact me if you don't understand why it is bad. Click to learn more.

 app.py

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