From d67697315cbb742b08d001eb4981b24fa530979f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20N=C3=BCst?= Date: Fri, 10 Oct 2025 13:09:07 +0200 Subject: [PATCH 1/5] Improves publication status visibility Enhances the user experience by restricting access to publications based on their status. - Introduces admin-only visibility for non-published publications on the works list and landing pages, allowing administrators to preview and manage content effectively. - Adds an "Edit in Admin" button for admins on the work landing page for quick access. - Implements permission checks in the API viewset to filter publications based on user roles. - Includes comprehensive tests to ensure the correct behavior for different user types and publication statuses. --- .claude/settings.local.json | 3 +- .claude/temp.md | 19 +- publications/templates/work_landing_page.html | 18 ++ publications/templates/works.html | 18 +- publications/views.py | 44 +++- publications/viewsets.py | 9 +- tests/test_work_landing_page.py | 231 ++++++++++++++++++ 7 files changed, 332 insertions(+), 10 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 3ef2ea7..9cce66a 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -7,7 +7,8 @@ "Bash(gh issue view:*)", "Bash(pytest:*)", "Bash(pip search:*)", - "Bash(psql:*)" + "Bash(psql:*)", + "Bash(OPTIMAP_LOGGING_LEVEL=WARNING python manage.py test tests.test_work_landing_page.PublicationStatusVisibilityTest)" ], "deny": [], "ask": [] diff --git a/.claude/temp.md b/.claude/temp.md index 9f72e92..387eca8 100644 --- a/.claude/temp.md +++ b/.claude/temp.md @@ -1,6 +1,21 @@ -# OPTIMAP +add a button to the work landing page for the logged in admin that takes the user directly to the editing view in the Django backend. +for the article http://127.0.0.1:8000/work/10.1007/s11368-020-02742-9/ with the internal ID 949 +the editing page is http://127.0.0.1:8000/admin/publications/publication/949/change/ -# geoextent +-- + +expand all harvesting to identify an existing OpenAlex record based on the available unique identifier and store the OpenAlex ID together with the record; if there is no perfet match then the property of the record should be set to None and a seperate field should indicate which partial match(es) were found and what kind of match it was (e.g. DOI match, title+author match, etc); + +expand all harvesting to include the messages that led to a warning log also in the email that is sent after the harvesting run, so that the user can see what went wrong without having to check the logs; + +-- + + +add feed-based harvesting support (RSS/Atom) for EarthArxiv; + +all articles from EarthArxiv are available via https://eartharxiv.org/repository/list/ + +there is a feed at https://eartharxiv.org/feed/ but it is unclear how many articles it contains diff --git a/publications/templates/work_landing_page.html b/publications/templates/work_landing_page.html index 0b1f7be..77eb345 100644 --- a/publications/templates/work_landing_page.html +++ b/publications/templates/work_landing_page.html @@ -18,6 +18,24 @@

{{ pub.title }}

+{% if is_admin and status_display %} +
+ Admin view: This publication has status {{ status_display }} + {% if pub.status != 'p' %} + and is not visible to the public. + {% endif %} + + Edit in Admin + +
+{% endif %} +
{% if authors_list %} Authors: {{ authors_list|join:", " }} · diff --git a/publications/templates/works.html b/publications/templates/works.html index 4f6ee94..f735644 100644 --- a/publications/templates/works.html +++ b/publications/templates/works.html @@ -12,9 +12,25 @@ {% block content %}

All Article Links

+ {% if is_admin %} +

+ Admin view: You can see all publications regardless of status. Status labels are shown next to each entry. +

+ {% endif %}
    {% for item in links %} -
  • {{ item.title }}
  • +
  • + {{ item.title }} + {% if is_admin and item.status %} + {{ item.status }} + {% endif %} +
  • {% empty %}
  • No publications found.
  • {% endfor %} diff --git a/publications/views.py b/publications/views.py index 7698479..53404fe 100644 --- a/publications/views.py +++ b/publications/views.py @@ -613,23 +613,55 @@ def works_list(request): Public page that lists a link for every work: - DOI present -> /work/ (site-local landing page) - no DOI -> fall back to Publication.url (external/original) + + Only published works (status='p') are shown to non-admin users. + Admin users see all works with status labels. """ - pubs = Publication.objects.all().order_by("-creationDate", "-id") + is_admin = request.user.is_authenticated and request.user.is_staff + + if is_admin: + pubs = Publication.objects.all().order_by("-creationDate", "-id") + else: + pubs = Publication.objects.filter(status='p').order_by("-creationDate", "-id") + links = [] for pub in pubs: + link_data = {"title": pub.title} + if pub.doi: - links.append({"title": pub.title, "href": reverse("optimap:article-landing", args=[pub.doi])}) + link_data["href"] = reverse("optimap:article-landing", args=[pub.doi]) elif pub.url: - links.append({"title": pub.title, "href": pub.url}) - return render(request, "works.html", {"links": links}) + link_data["href"] = pub.url + + # Add status info for admin users + if is_admin: + link_data["status"] = pub.get_status_display() + link_data["status_code"] = pub.status + + links.append(link_data) + + return render(request, "works.html", {"links": links, "is_admin": is_admin}) def work_landing(request, doi): """ Landing page for a publication with a DOI. Embeds a small Leaflet map when geometry is available. + + Only published works (status='p') are accessible to non-admin users. + Admin users can view all works with a status label. """ - pub = get_object_or_404(Publication, doi=doi) + is_admin = request.user.is_authenticated and request.user.is_staff + + # Get the publication + try: + pub = Publication.objects.get(doi=doi) + except Publication.DoesNotExist: + raise Http404("Publication not found.") + + # Check access permissions + if not is_admin and pub.status != 'p': + raise Http404("Publication not found.") feature_json = None if pub.geometry and not pub.geometry.empty: @@ -645,5 +677,7 @@ def work_landing(request, doi): "feature_json": feature_json, "timeperiod_label": _format_timeperiod(pub), "authors_list": _normalize_authors(pub), + "is_admin": is_admin, + "status_display": pub.get_status_display() if is_admin else None, } return render(request, "work_landing_page.html", context) \ No newline at end of file diff --git a/publications/viewsets.py b/publications/viewsets.py index a653590..a9ba4cb 100644 --- a/publications/viewsets.py +++ b/publications/viewsets.py @@ -20,7 +20,14 @@ class PublicationViewSet(viewsets.ReadOnlyModelViewSet): filter_backends = (filters.InBBoxFilter,) serializer_class = PublicationSerializer permission_classes = [IsAuthenticatedOrReadOnly] - queryset = Publication.objects.filter(status="p").distinct() + + def get_queryset(self): + """ + Return all publications for admin users, only published ones for others. + """ + if self.request.user.is_authenticated and self.request.user.is_staff: + return Publication.objects.all().distinct() + return Publication.objects.filter(status="p").distinct() class SubscriptionViewSet(viewsets.ModelViewSet): """ diff --git a/tests/test_work_landing_page.py b/tests/test_work_landing_page.py index 51fae8e..5827b99 100644 --- a/tests/test_work_landing_page.py +++ b/tests/test_work_landing_page.py @@ -4,6 +4,9 @@ from django.contrib.gis.geos import Point, GeometryCollection from django.utils.timezone import now from datetime import timedelta +from django.contrib.auth import get_user_model + +User = get_user_model() class WorkLandingPageTest(TestCase): @@ -167,3 +170,231 @@ def test_html_title_format(self): # Verify the title appears in the section (use re.DOTALL for multiline) import re self.assertIsNotNone(re.search(r'.*.*.*', content, re.DOTALL)) + + +class PublicationStatusVisibilityTest(TestCase): + """Tests for publication status visibility controls.""" + + def setUp(self): + self.client = Client() + + # Create test source + self.source = Source.objects.create( + name="Test Journal", + url_field="https://example.com/oai", + homepage_url="https://example.com/journal", + issn_l="1234-5678" + ) + + # Create publications with different statuses + self.pub_published = Publication.objects.create( + title="Published Publication", + abstract="This is published", + url="https://example.com/published", + status="p", # Published + doi="10.1234/published", + publicationDate=now() - timedelta(days=30), + geometry=GeometryCollection(Point(12.4924, 41.8902)), + source=self.source + ) + + self.pub_draft = Publication.objects.create( + title="Draft Publication", + abstract="This is a draft", + url="https://example.com/draft", + status="d", # Draft + doi="10.1234/draft", + publicationDate=now() - timedelta(days=20), + geometry=GeometryCollection(Point(13.4050, 52.5200)), + source=self.source + ) + + self.pub_testing = Publication.objects.create( + title="Testing Publication", + abstract="This is for testing", + url="https://example.com/testing", + status="t", # Testing + doi="10.1234/testing", + publicationDate=now() - timedelta(days=10), + source=self.source + ) + + self.pub_withdrawn = Publication.objects.create( + title="Withdrawn Publication", + abstract="This is withdrawn", + url="https://example.com/withdrawn", + status="w", # Withdrawn + doi="10.1234/withdrawn", + publicationDate=now() - timedelta(days=5), + source=self.source + ) + + self.pub_harvested = Publication.objects.create( + title="Harvested Publication", + abstract="This is harvested", + url="https://example.com/harvested", + status="h", # Harvested + doi="10.1234/harvested", + publicationDate=now() - timedelta(days=3), + source=self.source + ) + + # Create regular user + self.regular_user = User.objects.create_user( + username='regular@example.com', + email='regular@example.com' + ) + + # Create admin user + self.admin_user = User.objects.create_user( + username='admin@example.com', + email='admin@example.com', + is_staff=True, + is_superuser=True + ) + + def test_works_list_public_only_shows_published(self): + """Test that non-authenticated users only see published works.""" + response = self.client.get('/works/') + self.assertEqual(response.status_code, 200) + + # Should show published + self.assertContains(response, self.pub_published.title) + + # Should NOT show other statuses + self.assertNotContains(response, self.pub_draft.title) + self.assertNotContains(response, self.pub_testing.title) + self.assertNotContains(response, self.pub_withdrawn.title) + self.assertNotContains(response, self.pub_harvested.title) + + def test_works_list_regular_user_only_shows_published(self): + """Test that regular users only see published works.""" + self.client.force_login(self.regular_user) + response = self.client.get('/works/') + self.assertEqual(response.status_code, 200) + + # Should show published + self.assertContains(response, self.pub_published.title) + + # Should NOT show other statuses + self.assertNotContains(response, self.pub_draft.title) + self.assertNotContains(response, self.pub_testing.title) + + def test_works_list_admin_shows_all_with_labels(self): + """Test that admin users see all publications with status labels.""" + self.client.force_login(self.admin_user) + response = self.client.get('/works/') + self.assertEqual(response.status_code, 200) + + # Should show all publications + self.assertContains(response, self.pub_published.title) + self.assertContains(response, self.pub_draft.title) + self.assertContains(response, self.pub_testing.title) + self.assertContains(response, self.pub_withdrawn.title) + self.assertContains(response, self.pub_harvested.title) + + # Should show status badges + self.assertContains(response, 'Published') + self.assertContains(response, 'Draft') + self.assertContains(response, 'Testing') + self.assertContains(response, 'Withdrawn') + self.assertContains(response, 'Harvested') + + # Should show admin notice + self.assertContains(response, 'Admin view') + + def test_work_landing_public_cannot_access_unpublished(self): + """Test that non-authenticated users cannot access unpublished works.""" + # Published should work + response = self.client.get(f'/work/{self.pub_published.doi}/') + self.assertEqual(response.status_code, 200) + + # Draft should return 404 + response = self.client.get(f'/work/{self.pub_draft.doi}/') + self.assertEqual(response.status_code, 404) + + # Testing should return 404 + response = self.client.get(f'/work/{self.pub_testing.doi}/') + self.assertEqual(response.status_code, 404) + + def test_work_landing_regular_user_cannot_access_unpublished(self): + """Test that regular users cannot access unpublished works.""" + self.client.force_login(self.regular_user) + + # Published should work + response = self.client.get(f'/work/{self.pub_published.doi}/') + self.assertEqual(response.status_code, 200) + + # Draft should return 404 + response = self.client.get(f'/work/{self.pub_draft.doi}/') + self.assertEqual(response.status_code, 404) + + def test_work_landing_admin_can_access_all_with_label(self): + """Test that admin users can access all publications with status labels.""" + self.client.force_login(self.admin_user) + + # Published should work without warning + response = self.client.get(f'/work/{self.pub_published.doi}/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.pub_published.title) + self.assertContains(response, 'Admin view') + self.assertContains(response, 'Published') + + # Draft should work with warning + response = self.client.get(f'/work/{self.pub_draft.doi}/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.pub_draft.title) + self.assertContains(response, 'Admin view') + self.assertContains(response, 'Draft') + self.assertContains(response, 'not visible to the public') + + # Testing should work with warning + response = self.client.get(f'/work/{self.pub_testing.doi}/') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Testing') + self.assertContains(response, 'not visible to the public') + + def test_api_viewset_queryset_filtering(self): + """Test that PublicationViewSet filters correctly based on user permissions.""" + from publications.viewsets import PublicationViewSet + from rest_framework.test import APIRequestFactory + from django.contrib.auth.models import AnonymousUser + + factory = APIRequestFactory() + + # Test anonymous user + request = factory.get('/api/v1/publications/') + request.user = AnonymousUser() + viewset = PublicationViewSet() + viewset.request = request + queryset = viewset.get_queryset() + + # Should only return published + self.assertIn(self.pub_published, queryset) + self.assertNotIn(self.pub_draft, queryset) + self.assertNotIn(self.pub_testing, queryset) + + # Test regular authenticated user + request = factory.get('/api/v1/publications/') + request.user = self.regular_user + viewset = PublicationViewSet() + viewset.request = request + queryset = viewset.get_queryset() + + # Should only return published + self.assertIn(self.pub_published, queryset) + self.assertNotIn(self.pub_draft, queryset) + + # Test admin user + request = factory.get('/api/v1/publications/') + request.user = self.admin_user + viewset = PublicationViewSet() + viewset.request = request + queryset = viewset.get_queryset() + + # Should return all publications + self.assertIn(self.pub_published, queryset) + self.assertIn(self.pub_draft, queryset) + self.assertIn(self.pub_testing, queryset) + self.assertIn(self.pub_withdrawn, queryset) + self.assertIn(self.pub_harvested, queryset) From 8258ccd4590aab27ee8fc56df4605d7729b375a7 Mon Sep 17 00:00:00 2001 From: uxairibrar Date: Tue, 23 Sep 2025 21:03:56 +0200 Subject: [PATCH 2/5] Added locate functionality --- publications/templates/footer.html | 1 + publications/templates/locate.html | 115 +++++++++++++++++++++++++++++ publications/urls.py | 1 + publications/views.py | 17 +++++ 4 files changed, 134 insertions(+) create mode 100644 publications/templates/locate.html diff --git a/publications/templates/footer.html b/publications/templates/footer.html index c052e0b..b229236 100644 --- a/publications/templates/footer.html +++ b/publications/templates/footer.html @@ -6,6 +6,7 @@ Accessibility Data & API Feeds + Locate Code (v{{ optimap_version }}) About / Contact / Imprint Data license: CC-0 diff --git a/publications/templates/locate.html b/publications/templates/locate.html new file mode 100644 index 0000000..8eb8681 --- /dev/null +++ b/publications/templates/locate.html @@ -0,0 +1,115 @@ +{% extends "main.html" %} + +{% block title %}Locate Publications | {% endblock %} + +{% block head %} +{{ block.super }} + +{% endblock %} + +{% block content %} + +
    +
    +
    +

    Locate Publications

    + +

    Help us locate scientific publications on the map!

    + +

    The publications listed below have been harvested from various scientific sources but do not have geolocation data yet. If you know the geographic location or area mentioned in any of these publications, you can help us improve our database.

    + + {% if total_count > 0 %} + + +
    + {% for publication in publications %} +
    +
    +
    +
    + {% if publication.doi %} + + {{ publication.title|truncatechars:100 }} + + + {% else %} + {{ publication.title|truncatechars:100 }} + {% endif %} +
    + + {% if publication.abstract %} +

    + {{ publication.abstract|truncatechars:200 }} +

    + {% endif %} + + +
    +
    +
    + {% endfor %} +
    + + {% if publications|length >= 20 %} + + {% endif %} + + {% else %} + + {% endif %} + +
    +

    How to help

    +

    If you recognize any publications that reference specific geographic locations, please contact us with the following information:

    +
      +
    • Publication DOI or title
    • +
    • Geographic coordinates (latitude/longitude) or place names
    • +
    • Brief description of the geographic relevance
    • +
    +

    + + Contact Us + + + Back to Map + +

    +
    +
    +
    + +{% endblock content %} \ No newline at end of file diff --git a/publications/urls.py b/publications/urls.py index 101978f..66372d6 100644 --- a/publications/urls.py +++ b/publications/urls.py @@ -56,5 +56,6 @@ GeoFeedByGeometry(feed_type_variant="georss"), name="feed-georss-by-slug",), path("feeds/geoatom//", GeoFeedByGeometry(feed_type_variant="geoatom"), name="feed-geoatom-by-slug"), + path('locate/', views.locate, name="locate"), ] diff --git a/publications/views.py b/publications/views.py index 53404fe..5b31b47 100644 --- a/publications/views.py +++ b/publications/views.py @@ -128,6 +128,23 @@ def download_geopackage(request): def main(request): return render(request, "main.html") +def locate(request): + # Get publications that are harvested but have no geometry + publications_query = Publication.objects.filter( + status='h', # Harvested status + geometry__isnull=True # No geometry data + ).order_by('-creationDate') + + total_count = publications_query.count() + # Limit to first 20 for performance (no pagination) + publications_without_geo = publications_query[:20] + + context = { + 'publications': publications_without_geo, + 'total_count': total_count, + } + return render(request, 'locate.html', context) + def about(request): return render(request, 'about.html') From 41ef6b78d265c7343c345a63139fcf59de711e00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20N=C3=BCst?= Date: Fri, 10 Oct 2025 13:20:50 +0200 Subject: [PATCH 3/5] Show all non-geo publications on the locate page Also adds provenance tracking for harvested publications --- publications/tasks.py | 53 ++++++++++++++++ publications/views.py | 8 ++- tests/test_harvesting_provenance.py | 98 +++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 1 deletion(-) create mode 100644 tests/test_harvesting_provenance.py diff --git a/publications/tasks.py b/publications/tasks.py index add923e..9c012dd 100644 --- a/publications/tasks.py +++ b/publications/tasks.py @@ -48,6 +48,33 @@ DOI_REGEX = re.compile(r'10\.\d{4,9}/[-._;()/:A-Z0-9]+', re.IGNORECASE) CACHE_DIR = Path(tempfile.gettempdir()) / 'optimap_cache' + +def get_or_create_admin_command_user(): + """ + Get or create a dedicated user for Django admin command operations. + This user is used as the creator for harvested publications. + + Returns: + User: The Django Admin Command user instance + """ + username = 'django_admin_command' + email = 'django_admin_command@system.local' + + user, created = User.objects.get_or_create( + username=username, + defaults={ + 'email': email, + 'is_active': False, # System user, not for login + 'is_staff': False, + } + ) + + if created: + logger.info("Created system user: %s", username) + + return user + + def _get_article_link(pub): """Prefer our site permalink if DOI exists, else fallback to original URL.""" if getattr(pub, "doi", None): @@ -273,6 +300,17 @@ def get_field(tag): try: with transaction.atomic(): + # Get system user for harvested publications + admin_user = get_or_create_admin_command_user() + + # Build provenance string + harvest_timestamp = timezone.now().isoformat() + provenance = ( + f"Harvested via OAI-PMH from {source.name} " + f"(URL: {source.url_field}) on {harvest_timestamp}. " + f"HarvestingEvent ID: {event.id}." + ) + pub = Publication.objects.create( title = title_value, abstract = abstract_text, @@ -285,6 +323,8 @@ def get_field(tag): timeperiod_startdate = period_start, timeperiod_enddate = period_end, job = event, + provenance = provenance, + created_by = admin_user, ) saved_count += 1 logger.info("Saved publication id=%s: %s", pub.id, title_value[:80] if title_value else 'No title') @@ -697,6 +737,17 @@ def parse_rss_feed_and_save_publications(feed_url, event: 'HarvestingEvent', max logger.debug("Publication already exists: %s", title[:50]) continue + # Get system user for harvested publications + admin_user = get_or_create_admin_command_user() + + # Build provenance string + harvest_timestamp = timezone.now().isoformat() + provenance = ( + f"Harvested via RSS/Atom feed from {source.name} " + f"(URL: {feed_url}) on {harvest_timestamp}. " + f"HarvestingEvent ID: {event.id}." + ) + # Create publication pub = Publication( title=title, @@ -709,6 +760,8 @@ def parse_rss_feed_and_save_publications(feed_url, event: 'HarvestingEvent', max timeperiod_startdate=[], timeperiod_enddate=[], geometry=GeometryCollection(), # No spatial data from RSS typically + provenance=provenance, + created_by=admin_user, ) pub.save() diff --git a/publications/views.py b/publications/views.py index 5b31b47..408d263 100644 --- a/publications/views.py +++ b/publications/views.py @@ -129,10 +129,16 @@ def main(request): return render(request, "main.html") def locate(request): + from django.contrib.gis.geos import GeometryCollection + from django.db.models import Q + # Get publications that are harvested but have no geometry + # Include both NULL geometries and empty GeometryCollections publications_query = Publication.objects.filter( status='h', # Harvested status - geometry__isnull=True # No geometry data + ).filter( + Q(geometry__isnull=True) | # NULL geometry + Q(geometry__isempty=True) # Empty GeometryCollection ).order_by('-creationDate') total_count = publications_query.count() diff --git a/tests/test_harvesting_provenance.py b/tests/test_harvesting_provenance.py new file mode 100644 index 0000000..cb8e098 --- /dev/null +++ b/tests/test_harvesting_provenance.py @@ -0,0 +1,98 @@ +"""Tests for harvesting provenance and user attribution.""" +import os +import django +from pathlib import Path +from django.test import TestCase + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'optimap.settings') +django.setup() + +from publications.models import Publication, Source, HarvestingEvent +from publications.tasks import ( + parse_oai_xml_and_save_publications, + parse_rss_feed_and_save_publications, + get_or_create_admin_command_user +) +from django.contrib.auth import get_user_model + +User = get_user_model() +BASE_TEST_DIR = Path(__file__).resolve().parent + + +class HarvestingProvenanceTest(TestCase): + """Test that harvested publications have provenance and creator information.""" + + def setUp(self): + """Set up test data.""" + self.source = Source.objects.create( + name="Test Journal", + url_field="https://example.com/oai", + homepage_url="https://example.com/journal" + ) + self.event = HarvestingEvent.objects.create( + source=self.source, + status="in_progress" + ) + + def test_admin_command_user_creation(self): + """Test that the admin command user is created correctly.""" + user = get_or_create_admin_command_user() + + self.assertIsNotNone(user) + self.assertEqual(user.username, 'django_admin_command') + self.assertEqual(user.email, 'django_admin_command@system.local') + self.assertFalse(user.is_active) + self.assertFalse(user.is_staff) + + # Calling again should return the same user, not create a new one + user2 = get_or_create_admin_command_user() + self.assertEqual(user.id, user2.id) + + def test_oai_pmh_harvesting_sets_provenance(self): + """Test that OAI-PMH harvesting sets provenance and created_by.""" + xml_path = BASE_TEST_DIR / 'harvesting' / 'source_1' / 'oai_dc.xml' + xml_bytes = xml_path.read_bytes() + + parse_oai_xml_and_save_publications(xml_bytes, self.event) + + # Check that publications were created + pubs = Publication.objects.filter(job=self.event) + self.assertGreater(pubs.count(), 0, "Should have created at least one publication") + + # Check first publication + pub = pubs.first() + + # Check created_by is set to admin command user + self.assertIsNotNone(pub.created_by) + self.assertEqual(pub.created_by.username, 'django_admin_command') + + # Check provenance is set + self.assertIsNotNone(pub.provenance) + self.assertIn('Harvested via OAI-PMH', pub.provenance) + self.assertIn(self.source.name, pub.provenance) + self.assertIn(self.source.url_field, pub.provenance) + self.assertIn(f'HarvestingEvent ID: {self.event.id}', pub.provenance) + + def test_rss_harvesting_sets_provenance(self): + """Test that RSS/Atom harvesting sets provenance and created_by.""" + rss_path = BASE_TEST_DIR / 'harvesting' / 'rss_feed_sample.xml' + feed_url = f"file://{rss_path}" + + parse_rss_feed_and_save_publications(feed_url, self.event) + + # Check that publications were created + pubs = Publication.objects.filter(job=self.event) + self.assertGreater(pubs.count(), 0, "Should have created at least one publication") + + # Check first publication + pub = pubs.first() + + # Check created_by is set to admin command user + self.assertIsNotNone(pub.created_by) + self.assertEqual(pub.created_by.username, 'django_admin_command') + + # Check provenance is set + self.assertIsNotNone(pub.provenance) + self.assertIn('Harvested via RSS/Atom', pub.provenance) + self.assertIn(self.source.name, pub.provenance) + self.assertIn(f'HarvestingEvent ID: {self.event.id}', pub.provenance) From 425381e0e9f6b19d925143913c31af2e441eff13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20N=C3=BCst?= Date: Tue, 14 Oct 2025 21:02:38 +0200 Subject: [PATCH 4/5] Improves publication metadata contribution workflow --- CHANGELOG.md | 86 +++ README.md | 50 ++ fixtures/test_data_optimap.json | 76 +++ publications/models.py | 1 + publications/static/css/main.css | 123 ++++ publications/static/js/main.js | 13 +- publications/templates/contribute.html | 109 ++++ publications/templates/footer.html | 2 +- publications/templates/locate.html | 115 ---- publications/templates/work_landing_page.html | 404 ++++++++++++- publications/templates/works.html | 4 + publications/urls.py | 15 +- publications/views.py | 98 ++- publications/views_geometry.py | 264 ++++++++ tests/test_geometry_contribution.py | 563 ++++++++++++++++++ tests/test_geometry_contribution_by_id.py | 204 +++++++ tests/test_status_workflow.py | 216 +++++++ tests/test_temporal_contribution.py | 300 ++++++++++ 18 files changed, 2493 insertions(+), 150 deletions(-) create mode 100644 publications/templates/contribute.html delete mode 100644 publications/templates/locate.html create mode 100644 publications/views_geometry.py create mode 100644 tests/test_geometry_contribution.py create mode 100644 tests/test_geometry_contribution_by_id.py create mode 100644 tests/test_status_workflow.py create mode 100644 tests/test_temporal_contribution.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 20b4bd3..4288ea6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,39 @@ ### Added +- **Temporal extent contribution** (`publications/views_geometry.py`, `work_landing_page.html`) + - Users can now contribute temporal extent (start/end dates) in addition to spatial extent + - Added temporal extent contribution form on work landing page with date validation + - Contribute page now lists publications missing either spatial OR temporal extent + - Works can be published with only spatial extent, only temporal extent, or both + - Support for flexible date formats: YYYY, YYYY-MM, or YYYY-MM-DD + - 12 new tests covering temporal contribution and publishing workflows + - Updated provenance tracking to log temporal extent contributions +- **Complete status workflow documentation** (`README.md`, `tests/test_status_workflow.py`) + - Documented all 6 publication statuses: Draft, Harvested, Contributed, Published, Testing, Withdrawn + - Added detailed workflow transitions and visibility rules + - 10 new compliance tests verifying status definitions and access controls + - Tests confirm only Published status is publicly visible + - Tests verify contribution only allowed for Harvested status +- **Map popup enhancement** (`publications/static/js/main.js`) + - Added "View Publication Details" button to map popups + - Links to work landing page for detailed publication view + - Supports both DOI-based (`/work//`) and ID-based (`/work//`) URLs + - Styled as Bootstrap primary button for visibility +- **Geometry contribution workflow enhancements** + - Added support for publications without DOI via ID-based URLs (`/work//`) + - ID-based API endpoints for contribution, publishing, and unpublishing + - Template automatically selects DOI or ID-based URLs based on publication + - 5 new tests covering ID-based contribution workflow +- **Admin unpublish functionality** (`publications/views_geometry.py`) + - `unpublish_work()` and `unpublish_work_by_id()` endpoints + - Changes published works to Draft status with provenance tracking + - Unpublish button on work landing page for admin users + - 4 new tests for unpublish workflow +- Test data fixtures with Contributed status records (`fixtures/test_data_optimap.json`) + - Records 903-904: Contributed publications with DOI and geometry + - Records 905-906: Publications without DOI (one harvested, one contributed) + - Realistic provenance information showing contribution workflow - **RSS/Atom feed harvesting support** (`publications/tasks.py`) - `parse_rss_feed_and_save_publications()` function for parsing RSS/Atom feeds - `harvest_rss_endpoint()` function for complete RSS harvesting workflow @@ -41,6 +74,59 @@ ### Changed +- **Contribution endpoint now accepts both spatial and temporal extents** + - `contribute_geometry_by_id()` renamed conceptually to handle both extent types + - Single endpoint can accept geometry, temporal extent, or both in one request + - Updated error messages to reflect dual-extent support + - Provenance notes now detail all changes made in single contribution +- **Publishing workflow updated for flexible extent requirements** + - Harvested publications can be published with spatial extent only, temporal extent only, or both + - Contributed publications can always be published regardless of extent + - Updated `publish_work_by_id()` to check for at least one extent type + - Error message: "Cannot publish harvested publication without spatial or temporal extent" +- **Contribute page query expanded** + - Now shows publications missing spatial extent OR temporal extent + - Previously only showed publications missing spatial extent + - Allows crowdsourcing of both types of metadata +- **Work landing page alerts updated** + - Dynamic messages show which extent types are missing + - Conditional UI shows geometry map tools and/or temporal form as needed + - Context variables `has_geometry` and `has_temporal` passed to template +- **Unified URL structure for work landing pages** + - Changed ID-based URLs from `/publication//` to `/work//` + - Both DOI-based and ID-based URLs now use `/work/` prefix for consistency + - Updated all templates, JavaScript, and tests to use new URL structure + - Legacy `/publication//` paths no longer exist +- **Refactored views_geometry.py to eliminate code duplication** + - DOI-based functions now wrap ID-based functions instead of duplicating logic + - `contribute_geometry()`, `publish_work()`, and `unpublish_work()` translate DOI to ID + - Core business logic consolidated in `*_by_id()` functions + - Reduced code from 375 lines to 240 lines (~36% reduction) + - Easier maintenance with single source of truth for each operation +- **Renamed "Locate" page to "Contribute"** for clarity + - URL changed from `/locate/` to `/contribute/` (legacy redirect in place) + - View function renamed: `locate()` → `contribute()` + - Template renamed: `locate.html` → `contribute.html` + - Footer link updated: "Locate" → "Contribute" + - Page title: "Locate Publications" → "Contribute Geolocation Data" + - Better reflects the crowdsourcing action users perform +- **Completely refactored contribute page layout** to fix text overflow + - Rewrote CSS from scratch with proper containment strategy + - Added `min-width: 0` to all flex items (critical for proper shrinking) + - Added `overflow: hidden` at card, card-body, and card-footer levels + - Changed DOI breaking from `break-word` to `break-all` for long identifiers + - Used `
    ` instead of `
    ` for footer metadata (better structure) + - Reduced button text: "Contribute geospatial metadata" → "Contribute metadata" + - Text now properly wraps within card boundaries with no overflow +- **Publishing workflow now supports harvested publications with geometry** + - `publish_work()` and `publish_work_by_id()` functions accept both Contributed and Harvested status + - Harvested publications can be published directly if they already have geometry + - Contributed publications can always be published (existing behavior) + - Updated provenance messages to indicate source status (Contributed vs Harvested) + - Test updated to verify harvested publication publishing +- **CSS organization improved** + - Moved all inline CSS from locate.html to central main.css file + - Better maintainability and consistent styling across pages - Fixed OAI-PMH harvesting test failures by updating response format parameters - Changed from invalid 'structured'/'raw' to valid 'geojson'/'wkt'/'wkb' formats - Updated test assertions to expect GeoJSON FeatureCollection diff --git a/README.md b/README.md index 5c70355..a00f644 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,56 @@ The OPTIMAP has the following features: - Start page with a full screen map (showing geometries and metadata) and a time line of the areas and time periods of interest for scientific publications - Passwordless login via email - RESTful API at `/api` +- **Crowdsourced metadata contribution**: Logged-in users can contribute spatial and temporal extent data for publications +- **Publication workflow**: Harvested → Contributed → Published status transitions with full provenance tracking +- **Admin controls**: Publish/unpublish functionality with audit trails + +## Publication Status Workflow + +Publications in OPTIMAP follow a status-based workflow with six possible states: + +### Status Definitions + +- **Draft** (`d`): Internal draft state. Not visible to public. Can be edited by admins. Created when unpublishing a published work. +- **Harvested** (`h`): Automatically harvested from OAI-PMH or RSS feeds. May or may not have spatial/temporal extent. Not publicly visible. +- **Contributed** (`c`): User has contributed spatial and/or temporal extent. Awaits admin review. Not publicly visible. +- **Published** (`p`): Public-facing works visible to all users via website, map, API, and feeds. +- **Testing** (`t`): Reserved for testing purposes. Not publicly visible. Admin access only. +- **Withdrawn** (`w`): Publication has been withdrawn or retracted. Not publicly visible. + +### Workflow Transitions + +**Harvesting → Publishing:** + +1. Publication harvested from external source → Status: **Harvested** (`h`) +2. User contributes spatial/temporal extent → Status: **Contributed** (`c`) +3. Admin reviews and approves → Status: **Published** (`p`) +4. If needed, admin can unpublish → Status: **Draft** (`d`) + +**Direct Publishing (Skip Contribution):** + +- Harvested publications with **at least one extent type** (spatial OR temporal) can be published directly by admins without user contribution + +**Contribution Requirements:** + +- Users can only contribute to publications with **Harvested** (`h`) status +- Harvested publications **without any extent** require user contribution before publishing +- Contributed publications can always be published after admin review + +**Visibility Rules:** + +- Only **Published** (`p`) status is visible to non-admin users +- All other statuses require admin privileges to view +- Published works appear in: main map, work list, API responses, RSS/Atom feeds + +**Extent Contribution:** + +- Users can contribute **spatial extent** (geographic location) via interactive map with drawing tools +- Users can contribute **temporal extent** (time period) via date form (formats: YYYY, YYYY-MM, YYYY-MM-DD) +- Both extent types can be contributed separately or together in a single submission +- Publications without DOI are supported via ID-based URLs (`/work//`) +- All contributions are tracked with full provenance (user, timestamp, changes) +- Contribute page lists publications missing either spatial OR temporal extent OPTIMAP is based on [Django](https://www.djangoproject.com/) (with [GeoDjango](https://docs.djangoproject.com/en/4.1/ref/contrib/gis/) and [Django REST framework](https://www.django-rest-framework.org/)) with a [PostgreSQL](https://www.postgresql.org/)/[PostGIS](https://postgis.net/) database backend. diff --git a/fixtures/test_data_optimap.json b/fixtures/test_data_optimap.json index b0f8dab..f6d2114 100644 --- a/fixtures/test_data_optimap.json +++ b/fixtures/test_data_optimap.json @@ -72,5 +72,81 @@ "timeperiod_enddate": "[\"2024\"]", "provenance": "Manually added from file test_data.json using the Django management script." } + }, + { + "model": "publications.publication", + "pk": 903, + "fields": { + "status": "c", + "title": "Contributed Paper - Hamburg Harbor Study", + "abstract": "This paper has been contributed by a user with geolocation data. It studies shipping traffic in Hamburg harbor.", + "publicationDate": "2022-05-15", + "doi": "10.5555/contrib1", + "url": "http://paper.url/contrib1", + "geometry": "SRID=4326;GEOMETRYCOLLECTION(POINT (9.9937 53.5511))", + "creationDate": "2023-01-15T10:20:30.086Z", + "lastUpdate": "2023-01-16T14:35:22.086Z", + "source": 9, + "timeperiod_startdate": "[\"2020\"]", + "timeperiod_enddate": "[\"2021\"]", + "provenance": "Harvested via OAI-PMH on 2023-01-15T10:20:30Z.\n\nGeometry contributed by user test_user@example.com on 2023-01-16T14:35:22Z. Changed geometry from empty to Point. Status changed from Harvested to Contributed." + } + }, + { + "model": "publications.publication", + "pk": 904, + "fields": { + "status": "c", + "title": "Contributed Paper - Bavarian Alps Research", + "abstract": "User-contributed geolocation for a study about alpine ecosystems in Bavaria.", + "publicationDate": "2023-03-20", + "doi": "10.5555/contrib2", + "url": "http://paper.url/contrib2", + "geometry": "SRID=4326;GEOMETRYCOLLECTION(POLYGON ((10.5 47.3, 10.5 47.7, 11.2 47.7, 11.2 47.3, 10.5 47.3)))", + "creationDate": "2023-06-10T08:15:45.086Z", + "lastUpdate": "2023-06-11T16:22:10.086Z", + "source": 9, + "timeperiod_startdate": "[\"2022-06\"]", + "timeperiod_enddate": "[\"2023-06\"]", + "provenance": "Harvested via OAI-PMH on 2023-06-10T08:15:45Z.\n\nGeometry contributed by user scientist@example.org on 2023-06-11T16:22:10Z. Changed geometry from empty to Polygon. Status changed from Harvested to Contributed." + } + }, + { + "model": "publications.publication", + "pk": 905, + "fields": { + "status": "h", + "title": "Harvested Paper Without DOI - Frankfurt Study", + "abstract": "This harvested paper has no DOI but has a URL identifier. It needs geolocation contribution.", + "publicationDate": "2022-08-10", + "doi": null, + "url": "http://repository.example.org/id/12345", + "geometry": "SRID=4326;GEOMETRYCOLLECTION EMPTY", + "creationDate": "2023-08-01T09:30:00.086Z", + "lastUpdate": "2023-08-01T09:30:00.086Z", + "source": 9, + "timeperiod_startdate": "[\"2021\"]", + "timeperiod_enddate": "[\"2022\"]", + "provenance": "Harvested via RSS feed on 2023-08-01T09:30:00Z from OPTIMAP Test Journal." + } + }, + { + "model": "publications.publication", + "pk": 906, + "fields": { + "status": "c", + "title": "Contributed Paper Without DOI - Stuttgart Research", + "abstract": "This paper was harvested without a DOI, but a user contributed geolocation data using the URL identifier.", + "publicationDate": "2023-02-14", + "doi": null, + "url": "http://repository.example.org/id/67890", + "geometry": "SRID=4326;GEOMETRYCOLLECTION(POINT (9.1829 48.7758))", + "creationDate": "2023-09-05T11:45:00.086Z", + "lastUpdate": "2023-09-06T15:20:30.086Z", + "source": 9, + "timeperiod_startdate": "[\"2022\"]", + "timeperiod_enddate": "[\"2023\"]", + "provenance": "Harvested via RSS feed on 2023-09-05T11:45:00Z from OPTIMAP Test Journal.\n\nGeometry contributed by user researcher@example.com on 2023-09-06T15:20:30Z. Changed geometry from empty to Point. Status changed from Harvested to Contributed." + } } ] diff --git a/publications/models.py b/publications/models.py index 7051b16..81b49dd 100644 --- a/publications/models.py +++ b/publications/models.py @@ -23,6 +23,7 @@ ("t", "Testing"), ("w", "Withdrawn"), ("h", "Harvested"), + ("c", "Contributed"), ) EMAIL_STATUS_CHOICES = [ diff --git a/publications/static/css/main.css b/publications/static/css/main.css index 274a2a9..52d0a46 100644 --- a/publications/static/css/main.css +++ b/publications/static/css/main.css @@ -253,3 +253,126 @@ h1.page-title { overflow-wrap: break-word; hyphens: auto; } + +/* Contribute page styles - completely refactored */ + +/* Main container */ +.locate-container { + max-width: 100%; + overflow-x: hidden; +} + +/* Grid layout for publication cards */ +.publication-grid { + display: grid; + grid-template-columns: 1fr; + gap: 1.5rem; + width: 100%; +} + +@media (min-width: 768px) { + .publication-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +/* Card wrapper with strict containment */ +.locate-card { + display: flex; + flex-direction: column; + height: 100%; + min-width: 0; /* Critical for flex items to shrink below content size */ + overflow: hidden; /* Prevent any overflow from card */ +} + +/* Card body with proper text containment */ +.locate-card .card-body { + flex: 1 1 auto; + display: flex; + flex-direction: column; + padding: 1.25rem; + overflow: hidden; /* Prevent overflow */ + min-width: 0; /* Allow shrinking */ +} + +/* Card title with forced wrapping */ +.locate-card .card-title { + font-size: 1.1rem; + margin-bottom: 0.75rem; + /* Force text wrapping */ + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + hyphens: auto; + /* Prevent overflow */ + overflow: hidden; + min-width: 0; +} + +/* Links inside titles must wrap */ +.locate-card .card-title a { + /* Force wrapping on links */ + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + /* Make link behave as block-level for wrapping */ + display: inline; + /* Ensure link can't exceed container */ + max-width: 100%; + overflow: hidden; +} + +/* Abstract area */ +.locate-card .abstract-area { + flex: 1 1 auto; + margin-bottom: 0.75rem; + overflow: hidden; + min-width: 0; +} + +/* Card text (abstract) */ +.locate-card .card-text { + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + hyphens: auto; +} + +/* Card footer with strict containment */ +.locate-card .card-footer { + margin-top: auto; + padding: 1rem; + border-top: 1px solid rgba(0,0,0,.125); + background-color: transparent; + /* Prevent overflow */ + overflow: hidden; + min-width: 0; +} + +/* Small text in footer */ +.locate-card .card-footer small { + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + display: block; +} + +/* DOI text with special breaking */ +.doi-text { + font-family: monospace; + font-size: 0.85em; + /* Break anywhere if needed for DOIs */ + word-break: break-all; + overflow-wrap: anywhere; + display: inline-block; + max-width: 100%; +} + +/* Buttons in footer */ +.locate-card .card-footer .btn { + width: 100%; + margin-top: 0.5rem; + /* Prevent button text from breaking card */ + word-wrap: break-word; + white-space: normal; +} diff --git a/publications/static/js/main.js b/publications/static/js/main.js index bfbd414..0b2116f 100644 --- a/publications/static/js/main.js +++ b/publications/static/js/main.js @@ -69,8 +69,17 @@ function publicationPopup(feature, layer) { const p = feature.properties; let html = '
    '; - // Title - if (p.title) html += `

    ${p.title}

    `; + // Title with link to work landing page + if (p.title) { + html += `

    ${p.title}

    `; + + // Add link to work landing page + if (p.doi) { + html += ``; + } else if (p.id) { + html += ``; + } + } // Source details from nested object if (p.source_details) { diff --git a/publications/templates/contribute.html b/publications/templates/contribute.html new file mode 100644 index 0000000..ecff247 --- /dev/null +++ b/publications/templates/contribute.html @@ -0,0 +1,109 @@ +{% extends "main.html" %} + +{% block title %}Contribute Geolocation Data | {% endblock %} + +{% block content %} + +
    +
    +
    +

    Contribute Geolocation Data

    + +

    Help us add geographic data to scientific publications!

    + +

    The publications listed below have been harvested from various scientific sources but do not have geolocation data yet. If you know the geographic location or area mentioned in any of these publications, you can help us improve our database.

    + + {% if total_count > 0 %} + + +
    + {% for publication in publications %} +
    +
    +
    + {% if publication.doi %} + + {{ publication.title|truncatechars:120 }} + + + {% else %} + {{ publication.title|truncatechars:120 }} + {% endif %} +
    + +
    + {% if publication.abstract %} +

    + {{ publication.abstract|truncatechars:180 }} +

    + {% endif %} +
    +
    + + +
    + {% endfor %} +
    + + {% if publications|length >= 20 %} + + {% endif %} + + {% else %} + + {% endif %} + +
    +

    How to help

    +

    If you recognize any publications that reference specific geographic locations, please login, confirm your account, and contribute metadata.

    +

    In case you find any errors in other metadata fields, if you see a work with incorrect location or time information, or if you find publications that should be included in our database, please contact us:

    +

    + + Contact Us + + + Back to Map + +

    +
    +
    +
    + +{% endblock content %} \ No newline at end of file diff --git a/publications/templates/footer.html b/publications/templates/footer.html index b229236..6b4f15c 100644 --- a/publications/templates/footer.html +++ b/publications/templates/footer.html @@ -6,7 +6,7 @@ Accessibility Data & API Feeds - Locate + Contribute Code (v{{ optimap_version }}) About / Contact / Imprint Data license: CC-0 diff --git a/publications/templates/locate.html b/publications/templates/locate.html deleted file mode 100644 index 8eb8681..0000000 --- a/publications/templates/locate.html +++ /dev/null @@ -1,115 +0,0 @@ -{% extends "main.html" %} - -{% block title %}Locate Publications | {% endblock %} - -{% block head %} -{{ block.super }} - -{% endblock %} - -{% block content %} - -
    -
    -
    -

    Locate Publications

    - -

    Help us locate scientific publications on the map!

    - -

    The publications listed below have been harvested from various scientific sources but do not have geolocation data yet. If you know the geographic location or area mentioned in any of these publications, you can help us improve our database.

    - - {% if total_count > 0 %} - - -
    - {% for publication in publications %} -
    -
    -
    -
    - {% if publication.doi %} - - {{ publication.title|truncatechars:100 }} - - - {% else %} - {{ publication.title|truncatechars:100 }} - {% endif %} -
    - - {% if publication.abstract %} -

    - {{ publication.abstract|truncatechars:200 }} -

    - {% endif %} - - -
    -
    -
    - {% endfor %} -
    - - {% if publications|length >= 20 %} - - {% endif %} - - {% else %} - - {% endif %} - -
    -

    How to help

    -

    If you recognize any publications that reference specific geographic locations, please contact us with the following information:

    -
      -
    • Publication DOI or title
    • -
    • Geographic coordinates (latitude/longitude) or place names
    • -
    • Brief description of the geographic relevance
    • -
    -

    - - Contact Us - - - Back to Map - -

    -
    -
    -
    - -{% endblock content %} \ No newline at end of file diff --git a/publications/templates/work_landing_page.html b/publications/templates/work_landing_page.html index 77eb345..ce1cc8d 100644 --- a/publications/templates/work_landing_page.html +++ b/publications/templates/work_landing_page.html @@ -15,6 +15,7 @@ {% block content %}
    +{% csrf_token %}

    {{ pub.title }}

    @@ -26,6 +27,7 @@

    {{ pub.title }}

    {% elif pub.status == 't' %}badge-warning {% elif pub.status == 'w' %}badge-danger {% elif pub.status == 'h' %}badge-info + {% elif pub.status == 'c' %}badge-primary {% endif %}">{{ status_display }} {% if pub.status != 'p' %} and is not visible to the public. @@ -59,25 +61,123 @@

    {{ pub.title }}

    {{ pub.abstract }}

    {% endif %} -{% if feature_json %} - - - - -
    +{%if show_provenance and pub.provenance %} +
    + Provenance information (admin only): +
    {{ pub.provenance }}
    +
    +{% endif %} + + + + +{% if can_contribute or is_admin %} + +{% endif %} + + + +{% if can_contribute %} +
    + Missing information: This publication is missing + {% if not has_geometry and not has_temporal %} + geographic location and temporal extent + {% elif not has_geometry %} + geographic location + {% elif not has_temporal %} + temporal extent (time period) + {% endif %}. + You can help by adding this information below! +
    +{% endif %} + +{% if can_publish %} +
    + Ready to publish: This contribution has been reviewed and is ready to be made public. + +
    +{% endif %} + +{% if can_unpublish %} +
    + Published work: This publication is currently public. + +
    +{% endif %} + +
    + +{% if can_contribute %} +
    + {% if not has_geometry %} +
    + +

    + Use the drawing tools on the map above to mark the geographic area, then click this button to submit. +

    +
    + {% endif %} + + {% if not has_temporal %} +
    +
    +
    Contribute temporal extent
    +
    +
    +

    Please provide the time period this research data covers (not the publication date).

    +
    +
    +
    + + + Formats: YYYY, YYYY-MM, or YYYY-MM-DD +
    +
    + + + Formats: YYYY, YYYY-MM, or YYYY-MM-DD +
    +
    + +
    +
    +
    + {% endif %} +
    +{% endif %} + + {% if can_contribute or is_admin %} + + + {% endif %} -{% endif %}

    View raw JSON from API diff --git a/publications/templates/works.html b/publications/templates/works.html index f735644..420da82 100644 --- a/publications/templates/works.html +++ b/publications/templates/works.html @@ -1,4 +1,7 @@ {% extends "base.html" %} + +{% block title %}All works - {% endblock %} + {% block navbar %}