Skip to content

Commit 599699a

Browse files
committed
Show grouped bucket blobs in a gallery
Added blob_group and blob_file classes and various utilities functions
1 parent d37bcb7 commit 599699a

File tree

9 files changed

+300
-3
lines changed

9 files changed

+300
-3
lines changed

blobs/blob_file.py

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import logging
2+
import os
3+
import re
4+
from dataclasses import dataclass, InitVar
5+
from google.cloud.storage import Blob
6+
from blobs.file_type import FileType
7+
8+
logging.basicConfig(level=logging.INFO)
9+
log = logging.getLogger()
10+
11+
IMAGE_EXTENSIONS = ['.jpg', '.jpeg', '.png']
12+
resized_pic_re = re.compile(r'(?P<group_name>.*)(-(?P<width>\d+)x(?P<height>\d+))')
13+
14+
15+
@dataclass
16+
class BlobFile:
17+
original_blob: InitVar[Blob]
18+
public_url: str = None
19+
name: str = None
20+
group_name: str = None
21+
file_type: FileType = None
22+
width: int = None
23+
height: int = None
24+
25+
def __post_init__(self, original_blob):
26+
self.public_url = original_blob.public_url
27+
self.name = original_blob.name
28+
self.group_name = get_group_name(self.name)
29+
log.debug(f'BLOB: {self.name}; GROUP: {self.group_name}')
30+
try:
31+
self.file_type, self.width, self.height = get_file_info(self.name)
32+
except:
33+
log.exception(f'Error trying to initialize blob_file for {self.name}')
34+
raise
35+
36+
37+
def get_group_name(name):
38+
"""
39+
Of a given wordpress file name (available in different sizes), return a group name to identify all images
40+
belonging to the same group
41+
42+
>>> get_group_name('media/2013/01/foo-150x150.png')
43+
'media/2013/01/foo'
44+
45+
>>> get_group_name('media/2013/01/foo-150x150')
46+
'media/2013/01/foo'
47+
48+
>>> get_group_name('media/2013/01/foo.png')
49+
'media/2013/01/foo'
50+
51+
>>> get_group_name('media/2013/01/foo150x150')
52+
'media/2013/01/foo150x150'
53+
54+
>>> get_group_name('media/2016/08/xk-detect-x380.jpg')
55+
'media/2016/08/xk-detect-x380'
56+
"""
57+
prefix, extension = os.path.splitext(name)
58+
59+
matches = resized_pic_re.search(prefix)
60+
if matches is None:
61+
return prefix
62+
group_name = matches.group('group_name')
63+
return group_name
64+
65+
66+
def get_file_info(name):
67+
"""
68+
Of a given wordpress file name (available in different sizes), return FileType, Width and Heigth
69+
70+
>>> get_file_info('foo-1024x768')
71+
(<FileType.Large: (1024,)>, 1024, 768)
72+
73+
>>> get_file_info('foo-768x1024')
74+
(<FileType.Large: (1024,)>, 768, 1024)
75+
76+
>>> get_file_info('foo-150x150.png')
77+
(<FileType.LittleSquare: (150,)>, 150, 150)
78+
79+
>>> get_file_info('foo-140x10000')
80+
(<FileType.Small: (1,)>, 140, 10000)
81+
82+
>>> get_file_info('foo.png')
83+
(<FileType.Original: (10000,)>, None, None)
84+
85+
>>> get_file_info('foo.pdf')
86+
(<FileType.Other: (0,)>, None, None)
87+
88+
>>> get_file_info('foo-bar-x380.jpg')
89+
(<FileType.Original: (10000,)>, None, None)
90+
91+
>>> get_file_info('foo-288x300.png')
92+
(<FileType.Thumbnail: (300,)>, 288, 300)
93+
"""
94+
95+
prefix, extension = os.path.splitext(name)
96+
97+
matches = resized_pic_re.search(prefix)
98+
99+
if matches is None and extension in IMAGE_EXTENSIONS:
100+
return FileType.Original, None, None
101+
elif matches is None and extension not in IMAGE_EXTENSIONS:
102+
return FileType.Other, None, None
103+
104+
matched_width = matches.group('width')
105+
matched_height = matches.group('height')
106+
if matched_width == '' or matched_height == '':
107+
return FileType.Original, None, None
108+
109+
width = int(matched_width)
110+
height = int(matched_height)
111+
112+
file_type = None
113+
114+
if width == 1024 or height == 1024:
115+
file_type = FileType.Large
116+
elif width == 768 or height == 768:
117+
file_type = FileType.MediumLarge
118+
elif width == 624 or height == 624:
119+
file_type = FileType.Medium
120+
elif width == 300 or height == 300:
121+
file_type = FileType.Thumbnail
122+
elif width == 150 and height == 150:
123+
file_type = FileType.LittleSquare
124+
elif width < 150 or height < 150:
125+
file_type = FileType.Small
126+
return file_type, width, height

blobs/blob_group.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
from dataclasses import dataclass, InitVar
2+
from typing import Dict, List
3+
4+
from google.cloud.storage import Blob
5+
6+
from blobs.blob_file import BlobFile
7+
from blobs.file_type import FileType, get_fallback
8+
9+
10+
@dataclass
11+
class BlobGroup:
12+
name: str
13+
files: Dict[FileType, BlobFile] = None
14+
thumbnail_url: str = None
15+
16+
def __init__(self, name: str):
17+
self.name = name
18+
self.files = dict()
19+
20+
def get_thumbnail(self):
21+
return get_blob_version(300, self.files)
22+
23+
24+
def get_blob_version(max_size: int, blob_files: Dict[FileType, BlobFile]):
25+
"""
26+
Return blob of maximum size, or the original, or None if the file is not an image
27+
"""
28+
29+
wished_file_type = FileType.Original
30+
if max_size <= 150:
31+
wished_file_type = FileType.LittleSquare
32+
elif max_size <= 300:
33+
wished_file_type = FileType.Thumbnail
34+
elif max_size <= 624:
35+
wished_file_type = FileType.Medium
36+
elif max_size <= 1024:
37+
wished_file_type = FileType.Large
38+
39+
available_file_types = [b.file_type for b in blob_files.values()]
40+
file_type = get_fallback(wished_file_type, available_file_types)
41+
return blob_files[file_type]
42+
43+
44+
def get_dict_blob_group(blobs: List[Blob]):
45+
"""
46+
From a given list of blobs, get a dictionary with keys = group names, values = BlobGroups
47+
"""
48+
result = dict()
49+
for b in blobs:
50+
blob_file = BlobFile(b)
51+
group_name = blob_file.group_name
52+
if group_name not in result:
53+
result[group_name] = BlobGroup(group_name)
54+
group = result[group_name]
55+
group.files[blob_file.file_type] = blob_file
56+
group.thumbnail_url = group.get_thumbnail().public_url
57+
return result

blobs/file_type.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from enum import Enum
2+
from typing import List
3+
4+
5+
class FileType(Enum):
6+
Other = 0, # This is for other file types, e.g. PDF, ZIP etc.
7+
Small = 1,
8+
LittleSquare = 150,
9+
Thumbnail = 300,
10+
Medium = 624,
11+
MediumLarge = 768,
12+
Large = 1024,
13+
Original = 10000,
14+
15+
16+
def get_fallback(wished: FileType, available: List[FileType]):
17+
"""
18+
>>> get_fallback(FileType.Thumbnail, [FileType.LittleSquare, FileType.Thumbnail, FileType.Medium])
19+
<FileType.Thumbnail: (300,)>
20+
>>> get_fallback(FileType.Large, [FileType.Thumbnail, FileType.Medium, FileType.Large])
21+
<FileType.Large: (1024,)>
22+
>>> get_fallback(FileType.LittleSquare, [FileType.Small, FileType.LittleSquare, FileType.Original])
23+
<FileType.LittleSquare: (150,)>
24+
>>> get_fallback(FileType.Thumbnail, [FileType.Original])
25+
<FileType.Original: (10000,)>
26+
"""
27+
28+
if wished in available:
29+
return wished
30+
for file_type in FileType:
31+
if file_type.value >= wished.value and file_type.value in available:
32+
return file_type
33+
return available[0]

blueprints/admin.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,11 @@
33
from flask import Blueprint, render_template, abort, request, redirect, url_for, current_app, flash
44
from flask_login import login_required
55

6+
from blobs.blob_group import get_dict_blob_group
67
from datastore_queries import get_all_posts
78
from datastore_queries_admin import *
89
from utils_flask import set_global_config
9-
from utils_google_cloud_bucket import upload_to_bucket
10+
from utils_google_cloud_bucket import upload_to_bucket, get_all_bucket_blobs
1011

1112
logging.basicConfig(level=logging.INFO)
1213
log = logging.getLogger()
@@ -101,7 +102,7 @@ def post_delete(post_id):
101102
abort(500)
102103

103104

104-
# UPLOAD
105+
# BUCKET
105106

106107
ALLOWED_EXTENSIONS = {'txt', 'pdf', 'png', 'jpg', 'jpeg', 'gif'}
107108

@@ -112,6 +113,7 @@ def allowed_file(filename):
112113

113114

114115
@admin.route('/upload', methods=['POST'])
116+
@login_required
115117
def upload_file():
116118
if 'file' not in request.files: # check if the post request has the file part
117119
flash('No file part')
@@ -127,6 +129,14 @@ def upload_file():
127129
}
128130

129131

132+
@admin.route('/uploads/')
133+
@login_required
134+
def uploads():
135+
bucket_blobs = get_all_bucket_blobs()
136+
result = get_dict_blob_group(bucket_blobs)
137+
return render_template('uploads.html', grouped_blobs=result)
138+
139+
130140
# ADMIN USERS
131141

132142
@admin.route('/users/')

main.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from flask_login import LoginManager
66
from jinja2 import evalcontextfilter
77
from markupsafe import escape, Markup
8+
from slugify import slugify
89

910
from configuration import LOCAL_DEVELOPMENT_MODE
1011
from user import User
@@ -42,6 +43,11 @@ def datetimeformat(value, format='%Y-%m-%d'):
4243
return value.strftime(format)
4344

4445

46+
@app.template_filter()
47+
def get_slug(value):
48+
return slugify(value)
49+
50+
4551
@app.template_filter()
4652
@evalcontextfilter
4753
def nl2br(eval_ctx, value):

requirements.txt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@ Jinja2
77
click
88
oauthlib==3.0.1
99
pyOpenSSL==19.0.0
10-
Flask-Login==0.4.1
10+
Flask-Login==0.4.1
11+
python-slugify

static/style.css

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,3 +124,7 @@ aside ul li a {
124124
color: #9f9f9f;
125125
text-decoration: none;
126126
}
127+
128+
.wrapped {
129+
word-wrap: break-word;
130+
}

templates/admin/uploads.html

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
6+
<title>Uploads | {{CONFIG['blog_name']}}</title>
7+
<script src="{{ url_for('static', filename='script.js') }}"></script>
8+
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Ifjh" crossorigin="anonymous">
9+
<link type="text/css" rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
10+
</head>
11+
<body class="bg-white">
12+
<div class="container-fluid">
13+
<h1><a href="/admin/">Admin</a></h1>
14+
15+
<div class="row text-center text-lg-left">
16+
{% for group_name, group in grouped_blobs.items() %}
17+
<div class="col-lg-3 col-md-4 col-6">
18+
<a href="javascript:void(0);" class="d-block mb-4 h-100" data-toggle="modal" data-target="#{{group_name|get_slug}}">
19+
<img class="img-fluid img-thumbnail" src="{{group.thumbnail_url}}" alt="{{group_name|get_slug}}">
20+
</a>
21+
</div>
22+
<div class="modal fade" id="{{group_name|get_slug}}" tabindex="-1" role="dialog" aria-hidden="true">
23+
<div class="modal-dialog modal-lg" role="document">
24+
<div class="modal-content">
25+
<div class="modal-header">
26+
<h5 class="modal-title" id="exampleModalLabel">{{group_name}}</h5>
27+
<button type="button" class="close" data-dismiss="modal" aria-label="Close">
28+
<span aria-hidden="true">&times;</span>
29+
</button>
30+
</div>
31+
<div class="modal-body wrapped">
32+
<p class="d-flex justify-content-center">
33+
<img class="img-fluid img-thumbnail" src="{{group.thumbnail_url}}" alt="{{group_name|get_slug}}">
34+
</p>
35+
{% for file_type, blob in group.files.items() %}
36+
<p>{{blob.file_type.name}}:<br/>
37+
<a href="{{blob.public_url}}" target="_blank">{{blob.public_url}}</a>
38+
</p>
39+
{% endfor %}
40+
</div>
41+
</div>
42+
</div>
43+
</div>
44+
{% endfor %}
45+
</div>
46+
</div>
47+
<script src="https://code.jquery.com/jquery-3.4.1.slim.min.js" integrity="sha384-J6qa4849blE2+poT4WnyKhv5vZF5SrPo0iEjwBvKU7imGFAV0wwj1yYfoRSJoZ+n" crossorigin="anonymous"></script>
48+
<script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" integrity="sha384-Q6E9RHvbIyZFJoft+2mJbHaEWldlvI9IOYy5n3zV9zzTtmI3UksdQRVvoxMfooAo" crossorigin="anonymous"></script>
49+
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" integrity="sha384-wfSDF2E50Y2D1uUdj0O3uMBJnjuUD4Ih7YwaYd1iqfktj0Uod8GCExl3Og8ifwB6" crossorigin="anonymous"></script>
50+
</body>
51+
</html>

utils_google_cloud_bucket.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,12 @@ def upload_to_bucket(file):
2929
blob = bucket.blob(blob_name)
3030
blob.upload_from_file(file)
3131
return blob.public_url
32+
33+
34+
def get_all_bucket_blobs():
35+
"""Lists all the blobs in the bucket."""
36+
settings = get_settings()
37+
bucket_name = settings['blog_config']['google_cloud_bucket_name']
38+
storage_client = storage.Client()
39+
blobs = storage_client.list_blobs(bucket_name)
40+
return list(blobs)

0 commit comments

Comments
 (0)