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