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