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