Skip to content

Commit 6475373

Browse files
authored
Merge pull request #212 from GeoinformationSystems/feature_Add_article_permalink_to_notifications-103
2 parents 49077af + 85f8771 commit 6475373

File tree

16 files changed

+868
-95
lines changed

16 files changed

+868
-95
lines changed

.claude/temp.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# OPTIMAP
2+
3+
4+
5+
# geoextent
6+

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,42 @@
2626

2727
- ...
2828

29+
## [0.2.0] - 2025-10-09
30+
31+
### Added
32+
33+
- Work landing page improvements:
34+
- Clickable DOI links to https://doi.org resolver
35+
- Clickable source links to journal homepages
36+
- Link to raw JSON API response
37+
- Publication title and DOI in HTML `<title>` tag
38+
- Map enhancements on work landing page:
39+
- Fullscreen control using Leaflet Fullscreen plugin
40+
- Custom "Zoom to All Features" button
41+
- Scroll wheel zoom enabled
42+
- Comprehensive test suite for work landing page (9 tests)
43+
- Comprehensive test suite for geoextent API (24 tests)
44+
45+
### Changed
46+
47+
- None.
48+
49+
### Fixed
50+
51+
- None.
52+
53+
### Deprecated
54+
55+
- None.
56+
57+
### Removed
58+
59+
- None.
60+
61+
### Security
62+
63+
- External links (DOI, source, API) now use `target="_blank"` with `rel="noopener"` for security
64+
2965
## [0.1.0] - 2025-04-16
3066

3167
### Added

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,9 @@ python -m smtpd -c DebuggingServer -n localhost:5587
229229
OPTIMAP_EMAIL_HOST=localhost
230230
OPTIMAP_EMAIL_PORT=5587
231231
```
232+
### Accessing list of article links
233+
234+
Visit the URL - http://127.0.0.1:8000/articles/links/
232235

233236
### Create Superusers/Admin
234237

optimap/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
__version__ = "0.1.2"
1+
__version__ = "0.2.0"
22
VERSION = __version__

publications/admin.py

Lines changed: 63 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22
logger = logging.getLogger(__name__)
33

44
from django.contrib import admin, messages
5+
from django.utils.html import format_html
6+
from django.conf import settings
7+
from django.core.mail import send_mail
58
from leaflet.admin import LeafletGeoAdmin
69
from publications.models import Publication, Source, HarvestingEvent, BlockedEmail, BlockedDomain, GlobalRegion
710
from import_export.admin import ImportExportModelAdmin
@@ -12,6 +15,8 @@
1215
from publications.models import CustomUser
1316
from publications.tasks import regenerate_geojson_cache
1417
from publications.tasks import regenerate_geopackage_cache
18+
from django.test import Client
19+
from django.http import HttpResponse
1520

1621
@admin.action(description="Mark selected publications as published")
1722
def make_public(modeladmin, request, queryset):
@@ -133,30 +138,68 @@ def regenerate_all_exports(modeladmin, request, queryset):
133138
messages.success(request, "GeoJSON & GeoPackage caches were regenerated.")
134139
except Exception as e:
135140
messages.error(request, f"Error during export regeneration: {e}")
136-
141+
137142
@admin.register(Publication)
138143
class PublicationAdmin(LeafletGeoAdmin, ImportExportModelAdmin):
139-
"""Publication Admin."""
140-
list_display = ("title", "doi", "creationDate", "lastUpdate", "created_by", "updated_by", "status", "provenance", "source")
141-
search_fields = ("title", "doi", "abstract", "source")
142-
list_filter = ("status", "creationDate")
143-
actions = [make_public, make_draft, regenerate_all_exports]
144-
145-
fields = (
146-
"title", "doi", "status", "source", "abstract",
147-
"geometry", "timeperiod_startdate", "timeperiod_enddate",
148-
"created_by", "updated_by", "provenance"
149-
)
144+
list_display = ("title", "doi", "has_permalink", "permalink_link",
145+
"creationDate", "lastUpdate", "created_by", "updated_by",
146+
"status", "provenance", "source")
147+
search_fields = ("title", "doi", "abstract", "source__name")
148+
list_filter = ("status", "creationDate")
149+
fields = ("title", "doi", "status", "source", "abstract",
150+
"geometry", "timeperiod_startdate", "timeperiod_enddate",
151+
"created_by", "updated_by", "provenance")
150152
readonly_fields = ("created_by", "updated_by")
151-
153+
actions = ["make_public", "make_draft", "regenerate_all_exports",
154+
"export_permalinks_csv", "email_permalinks_preview"]
155+
156+
@admin.display(boolean=True, description="Has DOI")
157+
def has_permalink(self, obj):
158+
return bool(obj.doi)
159+
160+
@admin.display(description="Permalink")
161+
def permalink_link(self, obj):
162+
url = obj.permalink()
163+
return format_html('<a href="{}" target="_blank">{}</a>', url, url) if url else "—"
164+
165+
def export_permalinks_csv(self, request, queryset):
166+
rows = [("title", "doi", "permalink")]
167+
rows += [(p.title or "", p.doi, p.permalink() or "")
168+
for p in queryset.only("title", "doi") if p.doi]
169+
if len(rows) == 1:
170+
self.message_user(request, "No items with DOI in selection.", level=messages.WARNING)
171+
return
172+
#esc = lambda v: f"\"{(v or '').replace('\"','\"\"')}\""
173+
escape_row = lambda v: '"{}"'.format((v or '').replace('"', '""'))
174+
csv = "\n".join(",".join(map(escape_row, r)) for r in rows)
175+
resp = HttpResponse(csv, content_type="text/csv; charset=utf-8")
176+
resp["Content-Disposition"] = 'attachment; filename="publication_permalinks.csv"'
177+
return resp
178+
export_permalinks_csv.short_description = "Export permalinks (CSV)"
179+
180+
def email_permalinks_preview(self, request, queryset):
181+
base = settings.BASE_URL.rstrip("/")
182+
c = Client()
183+
lines, ok, bad = [], 0, 0
184+
for p in queryset.only("title", "doi"):
185+
if not p.doi:
186+
continue
187+
url = p.permalink()
188+
path = url[len(base):] if url and url.startswith(base) else url
189+
status = c.get(path).status_code
190+
ok += (status == 200); bad += (status != 200)
191+
lines.append(f"{'✅' if status == 200 else '❌'} {p.title}{url} (HTTP {status})")
192+
if not lines:
193+
self.message_user(request, "No items with DOI in selection.", level=messages.WARNING)
194+
return
195+
send_mail(
196+
"OPTIMAP — Permalink preview",
197+
"Selected publication permalinks:\n\n" + "\n".join(lines) + f"\n\nSummary: {ok} OK, {bad} not OK",
198+
settings.EMAIL_HOST_USER, [request.user.email]
199+
)
200+
self.message_user(request, f"Emailed preview to {request.user.email}.", level=messages.INFO)
201+
email_permalinks_preview.short_description = "Email permalinks preview to me"
152202

153-
@admin.register(Source)
154-
class SourceAdmin(admin.ModelAdmin):
155-
list_display = ("id", "url_field", "harvest_interval_minutes", "last_harvest", "collection_name", "tags")
156-
list_filter = ("harvest_interval_minutes", "collection_name")
157-
search_fields = ("url_field", "collection_name", "tags")
158-
actions = [trigger_harvesting_for_specific, trigger_harvesting_for_all, schedule_harvesting]
159-
160203
@admin.register(HarvestingEvent)
161204
class HarvestingEventAdmin(admin.ModelAdmin):
162205
list_display = ("id", "source", "status", "started_at", "completed_at")

publications/models.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from django.utils.timezone import now
1010
from django.contrib.auth.models import AbstractUser, Group, Permission
1111
from import_export import fields, resources
12+
from django.urls import reverse
1213
from import_export.widgets import ForeignKeyWidget
1314
from django.core.exceptions import ValidationError
1415
from stdnum.issn import is_valid as is_valid_issn
@@ -24,6 +25,11 @@
2425
("h", "Harvested"),
2526
)
2627

28+
EMAIL_STATUS_CHOICES = [
29+
("success", "Success"),
30+
("failed", "Failed"),
31+
]
32+
2733
class CustomUser(AbstractUser):
2834
groups = models.ManyToManyField(Group, related_name="publications_users", blank=True)
2935
user_permissions = models.ManyToManyField(Permission, related_name="publications_users_permissions", blank=True)
@@ -53,20 +59,31 @@ class Publication(models.Model):
5359
verbose_name='Publication geometry/ies', srid=4326, null=True, blank=True
5460
)
5561
timeperiod_startdate = ArrayField(models.CharField(max_length=1024, null=True), null=True, blank=True)
56-
timeperiod_enddate = ArrayField(models.CharField(max_length=1024, null=True), null=True, blank=True)
62+
timeperiod_enddate = ArrayField(models.CharField(max_length=1024, null=True), null=True, blank=True)
5763
job = models.ForeignKey(
5864
'HarvestingEvent', on_delete=models.CASCADE, related_name='publications', null=True, blank=True
5965
)
6066

6167
class Meta:
6268
ordering = ['-id']
6369
constraints = [
64-
models.UniqueConstraint(fields=['doi', 'url'], name='unique_publication_entry')
70+
models.UniqueConstraint(fields=['doi', 'url'], name='unique_publication_entry'),
6571
]
6672

6773
def __str__(self):
6874
return self.title
6975

76+
def permalink(self) -> str | None:
77+
"""
78+
Return the absolute OPTIMAP permalink (/work/<doi>) if a DOI exists; otherwise None.
79+
"""
80+
if not getattr(self, "doi", None):
81+
return None
82+
base = settings.BASE_URL.rstrip("/")
83+
rel = reverse("optimap:article-landing", args=[self.doi])
84+
return f"{base}{rel}"
85+
permalink.short_description = "Permalink"
86+
7087
class Subscription(models.Model):
7188
user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, related_name="subscriptions", null=True, blank=True)
7289
name = models.CharField(max_length=4096, default="default_subscription")

publications/static/css/main.css

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,3 +181,75 @@ main {
181181
word-wrap: break-word;
182182
overflow-wrap: break-word;
183183
}
184+
185+
/* Work landing page styles */
186+
h1.page-title {
187+
margin-top: 2rem;
188+
margin-bottom: .5rem;
189+
word-wrap: break-word;
190+
overflow-wrap: break-word;
191+
hyphens: auto;
192+
}
193+
194+
.muted {
195+
color: #666;
196+
font-size: .92rem;
197+
word-wrap: break-word;
198+
overflow-wrap: break-word;
199+
}
200+
201+
.meta {
202+
margin: 1rem 0 1.5rem;
203+
word-wrap: break-word;
204+
overflow-wrap: break-word;
205+
}
206+
207+
.meta a {
208+
word-wrap: break-word;
209+
overflow-wrap: break-word;
210+
word-break: break-all;
211+
}
212+
213+
#mini-map {
214+
height: 280px;
215+
border-radius: 8px;
216+
margin: 1.5rem 0;
217+
}
218+
219+
/* Custom zoom to all features button */
220+
.leaflet-control-zoom-all {
221+
background-color: white;
222+
border: 2px solid rgba(0,0,0,0.2);
223+
border-radius: 4px;
224+
width: 26px;
225+
height: 26px;
226+
line-height: 26px;
227+
text-align: center;
228+
cursor: pointer;
229+
font-size: 18px;
230+
font-weight: bold;
231+
}
232+
233+
.leaflet-control-zoom-all:hover {
234+
background-color: #f4f4f4;
235+
}
236+
237+
/* Works page styles */
238+
.works-page h2 {
239+
word-wrap: break-word;
240+
overflow-wrap: break-word;
241+
hyphens: auto;
242+
}
243+
244+
.works-page ul li {
245+
word-wrap: break-word;
246+
overflow-wrap: break-word;
247+
hyphens: auto;
248+
}
249+
250+
/* General text wrapping for paragraphs in content pages */
251+
.work-landing-page p {
252+
word-wrap: break-word;
253+
overflow-wrap: break-word;
254+
hyphens: auto;
255+
}

0 commit comments

Comments
 (0)