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