Skip to content

Commit 006564b

Browse files
committed
Merge branch 'main' of github.com:diverso-lab/uvlhub
2 parents 8d55087 + 26aab47 commit 006564b

File tree

10 files changed

+340
-26
lines changed

10 files changed

+340
-26
lines changed

.github/ISSUE_TEMPLATE/bug.yml

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
name: Bug report
2+
description: Report a problem or unexpected behavior
3+
title: "[Bug]: "
4+
labels: ["bug"]
5+
body:
6+
- type: textarea
7+
id: description
8+
attributes:
9+
label: Description
10+
description: What happened?
11+
placeholder: Tell us what you saw.
12+
validations:
13+
required: true
14+
15+
- type: textarea
16+
id: steps
17+
attributes:
18+
label: Steps to reproduce
19+
placeholder: |
20+
1. ...
21+
2. ...
22+
3. ...
23+
validations:
24+
required: true
25+
26+
- type: textarea
27+
id: expected
28+
attributes:
29+
label: Expected behavior
30+
placeholder: What should have happened?
31+
32+
- type: input
33+
id: environment
34+
attributes:
35+
label: Environment
36+
placeholder: OS, version, browser, etc.

.github/ISSUE_TEMPLATE/config.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
blank_issues_enabled: false

.github/ISSUE_TEMPLATE/feature.yml

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
name: Feature request
2+
description: Suggest an improvement or new feature
3+
title: "[Feature]: "
4+
labels: ["enhancement"]
5+
body:
6+
- type: textarea
7+
id: problem
8+
attributes:
9+
label: Problem
10+
description: What problem are you trying to solve?
11+
validations:
12+
required: true
13+
14+
- type: textarea
15+
id: proposal
16+
attributes:
17+
label: Proposed solution
18+
description: What do you want to happen?
19+
validations:
20+
required: true
21+
22+
- type: textarea
23+
id: context
24+
attributes:
25+
label: Additional context
26+
description: Links, screenshots, alternatives considered, etc.

app/modules/dataset/routes.py

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@
33
import shutil
44
import tempfile
55
from datetime import datetime
6+
from io import BytesIO
67

8+
import qrcode
79
from flask import (
810
abort,
911
current_app,
@@ -17,6 +19,9 @@
1719
url_for,
1820
)
1921
from flask_login import current_user, login_required
22+
from PIL import Image, ImageDraw
23+
from qrcode.image.styledpil import StyledPilImage
24+
from qrcode.image.styles.moduledrawers.pil import RoundedModuleDrawer
2025

2126
from app.modules.apikeys.decorators import require_api_key
2227
from app.modules.dataset import dataset_bp
@@ -203,6 +208,94 @@ def download_dataset(dataset_id):
203208
return resp
204209

205210

211+
def _build_dataset_qr_response(dataset: DataSet):
212+
if not dataset.ds_meta_data.dataset_doi:
213+
abort(404, description="QR available only for synchronized datasets with DOI.")
214+
215+
target_url = url_for("dataset.subdomain_index", doi=dataset.ds_meta_data.dataset_doi, _external=True)
216+
qr = qrcode.QRCode(
217+
version=None,
218+
error_correction=qrcode.constants.ERROR_CORRECT_H,
219+
box_size=10,
220+
border=5,
221+
)
222+
qr.add_data(target_url)
223+
qr.make(fit=True)
224+
225+
img = qr.make_image(
226+
image_factory=StyledPilImage,
227+
module_drawer=RoundedModuleDrawer(radius_ratio=0.9),
228+
fill_color="black",
229+
back_color="white",
230+
).convert("RGBA")
231+
232+
logo_path = os.path.join(current_app.root_path, "static", "media", "logos", "uvlhub_ball.png")
233+
if os.path.exists(logo_path):
234+
logo = Image.open(logo_path).convert("RGBA")
235+
qr_width, qr_height = img.size
236+
237+
logo_size = max(40, qr_width // 5)
238+
logo.thumbnail((logo_size, logo_size), Image.Resampling.LANCZOS)
239+
240+
padding = max(4, logo_size // 12)
241+
logo_background = Image.new(
242+
"RGBA",
243+
(logo.width + (2 * padding), logo.height + (2 * padding)),
244+
(0, 0, 0, 0),
245+
)
246+
bg_draw = ImageDraw.Draw(logo_background)
247+
bg_radius = max(6, logo_background.width // 5)
248+
bg_draw.rounded_rectangle(
249+
[(0, 0), (logo_background.width - 1, logo_background.height - 1)],
250+
radius=bg_radius,
251+
fill=(255, 255, 255, 255),
252+
)
253+
254+
rounded_logo = Image.new("RGBA", logo.size, (0, 0, 0, 0))
255+
logo_mask = Image.new("L", logo.size, 0)
256+
logo_mask_draw = ImageDraw.Draw(logo_mask)
257+
logo_radius = max(4, logo.width // 5)
258+
logo_mask_draw.rounded_rectangle(
259+
[(0, 0), (logo.width - 1, logo.height - 1)],
260+
radius=logo_radius,
261+
fill=255,
262+
)
263+
rounded_logo.paste(logo, (0, 0), logo_mask)
264+
265+
bg_position = ((qr_width - logo_background.width) // 2, (qr_height - logo_background.height) // 2)
266+
img.paste(logo_background, bg_position, logo_background)
267+
268+
logo_position = ((qr_width - rounded_logo.width) // 2, (qr_height - rounded_logo.height) // 2)
269+
img.paste(rounded_logo, logo_position, rounded_logo)
270+
271+
img_io = BytesIO()
272+
img.save(img_io, format="PNG")
273+
img_io.seek(0)
274+
275+
return send_file(
276+
img_io,
277+
mimetype="image/png",
278+
as_attachment=True,
279+
download_name=f"dataset_{dataset.id}_qr.png",
280+
)
281+
282+
283+
@dataset_bp.route("/datasets/<int:dataset_id>/qr", methods=["GET"])
284+
@dataset_bp.route("/datasets/<int:dataset_id>/qr/", methods=["GET"])
285+
def dataset_qr_by_id(dataset_id):
286+
dataset = dataset_service.get_or_404(dataset_id)
287+
return _build_dataset_qr_response(dataset)
288+
289+
290+
@dataset_bp.route("/doi/<path:doi>/qr", methods=["GET"])
291+
@dataset_bp.route("/doi/<path:doi>/qr/", methods=["GET"])
292+
def dataset_qr_by_doi(doi):
293+
ds_meta_data = dsmetadata_service.filter_by_doi(doi)
294+
if not ds_meta_data:
295+
abort(404, description="Dataset not found for the given DOI.")
296+
return _build_dataset_qr_response(ds_meta_data.dataset)
297+
298+
206299
@dataset_bp.route("/datasets/download/all", methods=["GET"])
207300
def download_all_dataset():
208301
selected_formats = request.args.getlist("formats")
@@ -260,6 +353,56 @@ def subdomain_index(doi):
260353
return resp
261354

262355

356+
@dataset_bp.route("/doi/<path:doi>/files/raw/<path:filename>", methods=["GET"])
357+
@dataset_bp.route("/doi/<path:doi>/files/raw/<path:filename>/", methods=["GET"])
358+
def doi_file_raw(doi, filename):
359+
360+
new_doi = doi_mapping_service.get_new_doi(doi)
361+
if new_doi:
362+
return redirect(
363+
url_for("dataset.doi_file_raw", doi=new_doi, filename=filename),
364+
code=302,
365+
)
366+
367+
ds_meta_data = dsmetadata_service.filter_by_doi(doi)
368+
if not ds_meta_data:
369+
abort(404)
370+
371+
dataset = ds_meta_data.dataset
372+
373+
selected_file = None
374+
for fm in dataset.feature_models:
375+
for hf in fm.hubfiles:
376+
if hf.name == filename:
377+
selected_file = hf
378+
break
379+
if selected_file:
380+
break
381+
382+
if not selected_file:
383+
abort(404, description="File not found in this DOI dataset")
384+
385+
file_path = os.path.join(
386+
current_app.root_path,
387+
"..",
388+
"uploads",
389+
f"user_{dataset.user_id}",
390+
f"dataset_{dataset.id}",
391+
"uvl",
392+
selected_file.name,
393+
)
394+
395+
if not os.path.exists(file_path):
396+
abort(404, description="File missing on disk")
397+
398+
return send_file(
399+
file_path,
400+
mimetype="text/plain; charset=utf-8",
401+
as_attachment=False,
402+
download_name=selected_file.name,
403+
)
404+
405+
263406
@dataset_bp.route("/datasets/unsynchronized/<int:dataset_id>/", methods=["GET"])
264407
@login_required
265408
@is_dataset_owner

app/modules/dataset/templates/dataset/view_dataset.html

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,14 +111,14 @@
111111
View associated publication
112112
</a>
113113
{% endif %}
114+
114115
</div>
115116
</div>
116117

117-
{% if dataset.ds_meta_data.dataset_doi %}
118+
{% if dataset.ds_meta_data.dataset_doi %}
118119
<!-- DOI + Copy icon -->
119120
<div class="row mt-3">
120121
<div class="col-12 d-flex align-items-center flex-wrap">
121-
122122
<!-- Etiqueta DOI -->
123123
<span class="px-3 py-1 bg-dark text-white fw-bold rounded-start" style="font-family: monospace;">
124124
DOI
@@ -141,6 +141,13 @@
141141
title="Copy DOI">
142142
</i>
143143

144+
<a href="{{ url_for('dataset.dataset_qr_by_id', dataset_id=dataset.id) }}"
145+
class="ms-2 d-inline-flex align-items-center text-dark text-decoration-none"
146+
data-bs-toggle="tooltip"
147+
title="Download QR">
148+
<i class="fa-solid fa-qrcode" style="font-size: 18px; line-height: 1;"></i>
149+
</a>
150+
144151
<!-- Texto oculto para copiar -->
145152
<div id="dataset_doi_uvlhub_{{ dataset.id }}" style="display: none;">
146153
{{ dataset.get_uvlhub_doi() }}
@@ -149,6 +156,7 @@
149156
</div>
150157
{% endif %}
151158

159+
152160
</div>
153161
</div>
154162

app/modules/dataset/tests/test_unit.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55
from flask import Response
66
from werkzeug.datastructures import MultiDict
7+
from werkzeug.exceptions import NotFound
78

89
from app import create_app
910
from app.modules.dataset import routes as dataset_routes
@@ -70,6 +71,77 @@ def test_subdomain_index_not_found(test_client):
7071
assert response.status_code == 404
7172

7273

74+
def _mock_dataset_for_qr(dataset_id=1, doi="10.1234/test-doi"):
75+
dataset = MagicMock()
76+
dataset.id = dataset_id
77+
dataset.ds_meta_data = MagicMock()
78+
dataset.ds_meta_data.dataset_doi = doi
79+
return dataset
80+
81+
82+
def test_dataset_qr_by_id_success():
83+
pytest.importorskip("qrcode")
84+
dataset_routes = __import__("app.modules.dataset.routes", fromlist=["routes"])
85+
app = create_app("testing")
86+
with (
87+
patch("app.modules.dataset.routes.dataset_service.get_or_404") as mock_get_or_404,
88+
patch("app.modules.dataset.routes._build_dataset_qr_response") as mock_build_qr,
89+
):
90+
mock_get_or_404.return_value = _mock_dataset_for_qr(dataset_id=7, doi="10.1234/dataset7")
91+
mock_build_qr.return_value = Response(b"qr-image", mimetype="image/png", status=200)
92+
with app.test_request_context("/datasets/7/qr"):
93+
response = dataset_routes.dataset_qr_by_id(7)
94+
95+
assert response.status_code == 200
96+
assert response.mimetype == "image/png"
97+
assert response.data == b"qr-image"
98+
mock_get_or_404.assert_called_once_with(7)
99+
mock_build_qr.assert_called_once()
100+
101+
102+
def test_dataset_qr_by_id_without_doi_returns_404():
103+
pytest.importorskip("qrcode")
104+
dataset_routes = __import__("app.modules.dataset.routes", fromlist=["routes"])
105+
app = create_app("testing")
106+
with patch("app.modules.dataset.routes.dataset_service.get_or_404") as mock_get_or_404:
107+
mock_get_or_404.return_value = _mock_dataset_for_qr(dataset_id=8, doi=None)
108+
with app.test_request_context("/datasets/8/qr"):
109+
with pytest.raises(NotFound):
110+
dataset_routes.dataset_qr_by_id(8)
111+
112+
113+
def test_dataset_qr_by_doi_success():
114+
pytest.importorskip("qrcode")
115+
dataset_routes = __import__("app.modules.dataset.routes", fromlist=["routes"])
116+
app = create_app("testing")
117+
dataset = _mock_dataset_for_qr(dataset_id=9, doi="10.1234/dataset9")
118+
with (
119+
patch("app.modules.dataset.routes.dsmetadata_service.filter_by_doi") as mock_filter_by_doi,
120+
patch("app.modules.dataset.routes._build_dataset_qr_response") as mock_build_qr,
121+
):
122+
mock_filter_by_doi.return_value = MagicMock(dataset=dataset)
123+
mock_build_qr.return_value = Response(b"qr-image", mimetype="image/png", status=200)
124+
with app.test_request_context("/doi/10.1234/dataset9/qr"):
125+
response = dataset_routes.dataset_qr_by_doi("10.1234/dataset9")
126+
127+
assert response.status_code == 200
128+
assert response.mimetype == "image/png"
129+
assert response.data == b"qr-image"
130+
mock_filter_by_doi.assert_called_once_with("10.1234/dataset9")
131+
mock_build_qr.assert_called_once_with(dataset)
132+
133+
134+
def test_dataset_qr_by_doi_not_found_returns_404():
135+
pytest.importorskip("qrcode")
136+
dataset_routes = __import__("app.modules.dataset.routes", fromlist=["routes"])
137+
app = create_app("testing")
138+
with patch("app.modules.dataset.routes.dsmetadata_service.filter_by_doi") as mock_filter_by_doi:
139+
mock_filter_by_doi.return_value = None
140+
with app.test_request_context("/doi/10.1234/non-existent/qr"):
141+
with pytest.raises(NotFound):
142+
dataset_routes.dataset_qr_by_doi("10.1234/non-existent")
143+
144+
73145
def _mock_dataset_for_edit():
74146
dataset = MagicMock()
75147
dataset.ds_meta_data = MagicMock()

0 commit comments

Comments
 (0)