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/CHANGELOG.md b/CHANGELOG.md index 20b4bd3..80329ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,135 +4,26 @@ ### Added -- **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 - - Support for RDF-based RSS feeds (Scientific Data journal) - - DOI extraction from multiple feed fields (prism:doi, dc:identifier) - - Duplicate detection by DOI and URL - - Abstract/description extraction from feed content -- feedparser library integration (v6.0.12) - - Added to requirements.txt for RSS/Atom feed parsing - - Supports RSS 1.0/2.0, Atom, and RDF feeds -- Django management command `harvest_journals` enhanced for RSS/Atom feeds - - Added Scientific Data journal with RSS feed support - - Support for both OAI-PMH and RSS/Atom feed types - - Automatic feed type detection based on journal configuration - - Now supports 4 journals: ESSD, AGILE-GISS, GEO-LEO (OAI-PMH), Scientific Data (RSS) -- Comprehensive RSS harvesting tests (`RSSFeedHarvestingTests`) - - 7 test cases covering RSS parsing, duplicate detection, error handling - - Test fixture with sample RDF/RSS feed (`tests/harvesting/rss_feed_sample.xml`) - - Tests for max_records limit, invalid feeds, and HTTP errors -- Django management command `harvest_journals` for harvesting real journal sources - - Command-line options for journal selection, record limits, and source creation - - Detailed progress reporting with colored output - - Statistics for spatial/temporal metadata extraction -- Integration tests for real journal harvesting (`tests/test_real_harvesting.py`) - - 6 tests covering ESSD, AGILE-GISS, GEO-LEO, and EssOAr - - Tests skipped by default (use `SKIP_REAL_HARVESTING=0` to enable) - - Max records parameter to limit harvesting for testing -- Comprehensive error handling tests for OAI-PMH harvesting (`HarvestingErrorTests`) - - 10 test cases covering malformed XML, missing metadata, HTTP errors, network timeouts - - Test fixtures for various error conditions in `tests/harvesting/error_cases/` - - Verification of graceful error handling and logging -- pytest configuration with custom markers (`pytest.ini`) - - `real_harvesting` marker for integration tests - - Configuration for Django test discovery +- **Temporal extent contribution** - Users can now contribute temporal extent (start/end dates) in addition to spatial extent. Works can be published with either spatial, temporal, or both extents. Supports flexible date formats: YYYY, YYYY-MM, YYYY-MM-DD. +- **Complete status workflow documentation** - Documented all 6 publication statuses (Draft, Harvested, Contributed, Published, Testing, Withdrawn) with workflow transitions and visibility rules in README.md. +- **Map popup enhancement** - Added "View Publication Details" button to map popups linking to work landing pages. +- **Admin unpublish functionality** - Admins can unpublish works, changing status from Published to Draft. +- **RSS/Atom feed harvesting support** - Added support for harvesting publications from RSS/Atom feeds in addition to OAI-PMH. +- **Django management command `harvest_journals`** - Command-line tool for harvesting from real journal sources with progress reporting and statistics. +- **Comprehensive test coverage** - Added 40+ new tests covering temporal contribution, status workflow, RSS harvesting, error handling, and real journal harvesting. ### Changed -- 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 -- Fixed syntax errors in `publications/tasks.py` - - Fixed import statement typo - - Fixed indentation in `extract_timeperiod_from_html` function - - Fixed misplaced return statement in `regenerate_geopackage_cache` function -- Fixed test setup method in `tests/test_harvesting.py` - - Removed incorrect `@classmethod` decorator from `setUp` method -- Fixed `test_regular_harvesting.py` to include `max_records` parameter in mock function -- Updated README.md with comprehensive documentation for: - - Integration test execution - - `harvest_journals` management command usage - - Journal harvesting workflows +- **Unified contribution workflow** - Single "Submit contribution" button for both spatial and temporal extent. Users can submit either or both in one action. +- **Unified admin control panel** - Consolidated admin status display, publish/unpublish buttons, and "Edit in Admin" link into single highlighted box at top of work landing page. +- **Improved text wrapping** - Page titles and abstract text now properly wrap on narrow windows instead of overflowing. +- **Unified URL structure** - Changed ID-based URLs from `/publication//` to `/work//` for consistency with DOI-based URLs. +- **Refactored views_geometry.py** - Eliminated code duplication by making DOI-based functions wrap ID-based functions. Reduced from 375 to 240 lines (~36% reduction). +- **Renamed "Locate" to "Contribute"** - URL, page title, and navigation updated for clarity about crowdsourcing purpose. +- **Contribute page layout refactored** - Fixed text overflow issues with proper CSS containment strategy. +- **Flexible publishing requirements** - Harvested publications with geometry can be published directly without requiring user contribution. +- **Contribute page login button improved** - Changed to informational disabled button with clear text: "Please log in to contribute (user menu at top right)". ### Fixed -- Docker build for geoextent installation (added git dependency to Dockerfile) -- 18 geoextent API test failures due to invalid response format values -- 8 test setup errors in OAI-PMH harvesting tests -- Test harvesting function signature mismatch - -### Deprecated - -- None. - -### Removed - -- None. - -### Security - -- None. - -## [0.2.0] - 2025-10-09 - -### Added - -- Work landing page improvements: - - Clickable DOI links to https://doi.org resolver - - Clickable source links to journal homepages - - Link to raw JSON API response - - Publication title and DOI in HTML `` tag -- Map enhancements on work landing page: - - Fullscreen control using Leaflet Fullscreen plugin - - Custom "Zoom to All Features" button - - Scroll wheel zoom enabled -- Comprehensive test suite for work landing page (9 tests) -- Comprehensive test suite for geoextent API (24 tests) - -### Changed - -- None. - -### Fixed - -- None. - -### Deprecated - -- None. - -### Removed - -- None. - -### Security - -- External links (DOI, source, API) now use `target="_blank"` with `rel="noopener"` for security - -## [0.1.0] - 2025-04-16 - -### Added - -- Changelog - -### Changed - -- None. - -### Fixed - -- None. - -### Deprecated - -- None. - -### Removed - -- None. - -### Security - -- None. +- **JavaScript scope error** - Fixed "drawnItems is not defined" error in contribution form by declaring variable in outer scope. 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/<id>/`) +- 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/optimap/__init__.py b/optimap/__init__.py index b08e2c7..fdefb43 100644 --- a/optimap/__init__.py +++ b/optimap/__init__.py @@ -1,2 +1,2 @@ -__version__ = "0.3.0" +__version__ = "0.4.0" VERSION = __version__ \ No newline at end of file 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..f96c0e3 100644 --- a/publications/static/css/main.css +++ b/publications/static/css/main.css @@ -183,12 +183,24 @@ main { } /* Work landing page styles */ +.work-landing-page { + max-width: 1200px; + margin: 0 auto; + padding: 0 1rem; + overflow-wrap: break-word; + word-wrap: break-word; + word-break: break-word; +} + h1.page-title { margin-top: 2rem; margin-bottom: .5rem; word-wrap: break-word; overflow-wrap: break-word; + word-break: break-word; hyphens: auto; + white-space: normal !important; + max-width: 100%; } .muted { @@ -196,12 +208,14 @@ h1.page-title { font-size: .92rem; word-wrap: break-word; overflow-wrap: break-word; + word-break: break-word; } .meta { margin: 1rem 0 1.5rem; word-wrap: break-word; overflow-wrap: break-word; + word-break: break-word; } .meta a { @@ -251,5 +265,137 @@ h1.page-title { .work-landing-page p { word-wrap: break-word; overflow-wrap: break-word; + word-break: break-word; + hyphens: auto; + max-width: 100%; + white-space: normal; +} + +/* Ensure abstract text wraps properly */ +.work-landing-page > p { + margin: 1rem 0; + line-height: 1.6; +} + +/* 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 = '<div>'; - // Title - if (p.title) html += `<h3>${p.title}</h3>`; + // Title with link to work landing page + if (p.title) { + html += `<h3>${p.title}</h3>`; + + // Add link to work landing page + if (p.doi) { + html += `<div style="margin-bottom: 10px;"><a href="/work/${encodeURIComponent(p.doi)}/" class="btn btn-sm btn-primary" style="color: white; text-decoration: none; padding: 5px 10px; border-radius: 3px; display: inline-block;">View Publication Details</a></div>`; + } else if (p.id) { + html += `<div style="margin-bottom: 10px;"><a href="/work/${p.id}/" class="btn btn-sm btn-primary" style="color: white; text-decoration: none; padding: 5px 10px; border-radius: 3px; display: inline-block;">View Publication Details</a></div>`; + } + } // Source details from nested object if (p.source_details) { 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/templates/contribute.html b/publications/templates/contribute.html new file mode 100644 index 0000000..b20c325 --- /dev/null +++ b/publications/templates/contribute.html @@ -0,0 +1,109 @@ +{% extends "main.html" %} + +{% block title %}Contribute Geolocation Data | {% endblock %} + +{% block content %} + +<div class="container-fluid locate-container"> + <div class="row justify-content-center"> + <div class="col-md-10 col-lg-8 py-5"> + <h1 class="py-2">Contribute Geolocation Data</h1> + + <p class="lead">Help us add geographic data to scientific publications!</p> + + <p class="text-wrap text-break">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.</p> + + {% if total_count > 0 %} + <div class="alert alert-info text-break" role="alert"> + <i class="fas fa-info-circle"></i> + <strong>{{ total_count }}</strong> publication{{ total_count|pluralize }} need{{ total_count|pluralize:"s," }} geolocation data. + </div> + + <div class="publication-grid"> + {% for publication in publications %} + <div class="card locate-card"> + <div class="card-body"> + <h5 class="card-title"> + {% if publication.doi %} + <a href="https://doi.org/{{ publication.doi }}" target="_blank" rel="noopener noreferrer"> + {{ publication.title|truncatechars:120 }} + <i class="fas fa-external-link-alt fa-xs"></i> + </a> + {% else %} + <span>{{ publication.title|truncatechars:120 }}</span> + {% endif %} + </h5> + + <div class="abstract-area"> + {% if publication.abstract %} + <p class="card-text text-muted small"> + {{ publication.abstract|truncatechars:180 }} + </p> + {% endif %} + </div> + </div> + + <div class="card-footer"> + <small class="text-muted"> + {% if publication.publicationDate %} + <div class="mb-1">Published: {{ publication.publicationDate|date:"M d, Y" }}</div> + {% endif %} + {% if publication.source %} + <div class="mb-1">Source: {{ publication.source.name }}</div> + {% endif %} + {% if publication.doi %} + <div class="mb-1">DOI: <span class="doi-text">{{ publication.doi }}</span></div> + {% endif %} + </small> + + {% if request.user.is_authenticated %} + {% if publication.doi %} + <a href="{% url 'optimap:article-landing' publication.doi %}" class="btn btn-primary btn-sm"> + <i class="fas fa-map-marked-alt"></i> Contribute metadata + </a> + {% else %} + <a href="{% url 'optimap:publication-by-id' publication.id %}" class="btn btn-primary btn-sm"> + <i class="fas fa-map-marked-alt"></i> Contribute metadata + </a> + {% endif %} + {% else %} + <button disabled class="btn btn-outline-secondary btn-sm" title="Please use the user menu at the top right to log in"> + <i class="fas fa-user-circle"></i> Please log in to contribute (user menu at top right) + </button> + {% endif %} + </div> + </div> + {% endfor %} + </div> + + {% if publications|length >= 20 %} + <div class="alert alert-secondary mt-4" role="alert"> + <i class="fas fa-info-circle"></i> + Showing first 20 publications only. More publications without geolocation data are available, please make your contributions and then refresh this page. If you would like to contribute for a specific journal or field, please get in touch. + </div> + {% endif %} + + {% else %} + <div class="alert alert-success" role="alert"> + <i class="fas fa-check-circle"></i> + Great! All harvested publications currently have geolocation data. + </div> + {% endif %} + + <div class="mt-5"> + <h3>How to help</h3> + <p>If you recognize any publications that reference specific geographic locations, please login, confirm your account, and contribute metadata.</p> + <p>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:</p> + <p> + <a href="/about" class="btn btn-primary"> + <i class="fas fa-envelope"></i> Contact Us + </a> + <a href="/" class="btn btn-outline-secondary"> + <i class="fas fa-map"></i> Back to Map + </a> + </p> + </div> + </div> +</div> + +{% endblock content %} \ No newline at end of file diff --git a/publications/templates/footer.html b/publications/templates/footer.html index c052e0b..6b4f15c 100644 --- a/publications/templates/footer.html +++ b/publications/templates/footer.html @@ -6,6 +6,7 @@ <a class="px-2 text-white" title="Accessibility / Barrierefreiheit" href="{% url 'optimap:accessibility' %}">Accessibility</a> <a class="px-2 text-white" title="Data & API documentation and browser" href="{% url 'optimap:data' %}">Data & API</a> <a class="px-3 text-white" title="Feeds" href="{% url 'optimap:feeds' %}">Feeds</a> + <a class="px-2 text-white" title="Help contribute geolocation data to publications" href="{% url 'optimap:contribute' %}">Contribute</a> <a class="px-2 text-white" title="Link to source code project" href="https://github.com/GeoinformationSystems/optimap">Code (v{{ optimap_version }})</a> <a class="px-2 text-white" title="About / Contact / Imprint" href="{% url 'optimap:about' %}">About / Contact / Imprint</a> <span class="px-3">Data license: <a class="text-white" title="Publication metadata license" href='https://creativecommons.org/publicdomain/zero/1.0/'>CC-0</a></span> diff --git a/publications/templates/work_landing_page.html b/publications/templates/work_landing_page.html index 0b1f7be..ef0f178 100644 --- a/publications/templates/work_landing_page.html +++ b/publications/templates/work_landing_page.html @@ -15,9 +15,65 @@ {% block content %} <div class="work-landing-page"> +{% csrf_token %} <h1 class="page-title">{{ pub.title }}</h1> +{% if is_admin %} + <div class="alert + {% if pub.status == 'p' %}alert-success + {% elif pub.status == 'c' %}alert-primary + {% else %}alert-warning + {% endif %}"> + <div class="d-flex justify-content-between align-items-center"> + <div> + <strong><i class="fas fa-user-shield"></i> Admin view:</strong> + Status: <span class="badge + {% if pub.status == 'p' %}badge-success + {% elif pub.status == 'd' %}badge-secondary + {% 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 }}</span> + {% if pub.status == 'p' %} + · This publication is public + {% elif pub.status == 'c' %} + · Ready to publish (contribution reviewed) + {% else %} + · Not visible to the public + {% endif %} + </div> + <div> + {% if can_publish %} + <button id="publish-btn" class="btn btn-success btn-sm mr-2"> + <i class="fas fa-check-circle"></i> Publish + </button> + {% endif %} + {% if can_unpublish %} + <button id="unpublish-btn" class="btn btn-warning btn-sm mr-2"> + <i class="fas fa-archive"></i> Unpublish + </button> + {% endif %} + <a href="/admin/publications/publication/{{ pub.id }}/change/" class="btn btn-primary btn-sm" target="_blank" rel="noopener"> + <i class="fas fa-edit"></i> Edit in Admin + </a> + </div> + </div> + {% if show_provenance and pub.provenance %} + <hr class="my-3"> + <div> + <a class="btn btn-sm btn-outline-secondary" data-toggle="collapse" href="#provenance-details" role="button" aria-expanded="false" aria-controls="provenance-details"> + <i class="fas fa-history"></i> Show provenance information + </a> + <div class="collapse mt-2" id="provenance-details"> + <pre style="white-space: pre-wrap; margin: 0; font-size: 0.85rem; background: rgba(255,255,255,0.5); padding: 10px; border-radius: 4px;">{{ pub.provenance }}</pre> + </div> + </div> + {% endif %} + </div> +{% endif %} + <div class="meta muted"> {% if authors_list %} <strong>Authors:</strong> {{ authors_list|join:", " }} · @@ -41,25 +97,125 @@ <h1 class="page-title">{{ pub.title }}</h1> <p>{{ pub.abstract }}</p> {% endif %} -{% if feature_json %} - <!-- Leaflet assets included here --> - <link rel="stylesheet" - href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" - integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" - crossorigin=""> - <!-- Leaflet Fullscreen plugin --> - <link rel="stylesheet" - href="https://unpkg.com/leaflet.fullscreen@3.0.2/Control.FullScreen.css" - crossorigin=""> - <div id="mini-map"></div> +<!-- Leaflet assets --> +<link rel="stylesheet" + href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" + integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" + crossorigin=""> +<!-- Leaflet Draw plugin for geometry editing --> +{% if can_contribute or is_admin %} +<link rel="stylesheet" + href="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.css" + crossorigin=""> +{% endif %} +<!-- Leaflet Fullscreen plugin --> +<link rel="stylesheet" + href="https://unpkg.com/leaflet.fullscreen@3.0.2/Control.FullScreen.css" + crossorigin=""> + +{% if can_contribute %} + <div class="alert alert-warning"> + <strong>Missing information:</strong> 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! + </div> +{% endif %} + +<div id="mini-map"></div> + +{% if can_contribute %} +<div class="card mt-3"> + <div class="card-header"> + <h5 class="mb-0"> + <i class="fas fa-hands-helping"></i> Contribute metadata + </h5> + </div> + <div class="card-body"> + {% if not has_geometry and not has_temporal %} + <p class="text-muted mb-3">This publication is missing both spatial and temporal extent. Please help by adding this information.</p> + {% elif not has_geometry %} + <p class="text-muted mb-3">This publication is missing spatial extent. {% if has_temporal %}Temporal extent data already exists but can be updated if needed.{% endif %}</p> + {% elif not has_temporal %} + <p class="text-muted mb-3">This publication is missing temporal extent. Spatial extent already exists.</p> + {% endif %} + + <form id="contribution-form"> + {% if not has_geometry %} + <div class="mb-4"> + <h6 class="font-weight-bold"><i class="fas fa-map-marked-alt"></i> Spatial extent (geographic location)</h6> + <p class="text-muted small">Use the drawing tools on the map above to mark the geographic area covered by this research.</p> + <div id="geometry-status" class="alert alert-warning"> + <i class="fas fa-info-circle"></i> <span id="geometry-status-text">No geometry drawn yet. Use the map tools above to add spatial extent.</span> + </div> + </div> + {% endif %} + + <div class="mb-4"> + <h6 class="font-weight-bold"><i class="fas fa-calendar"></i> Temporal extent (time period)</h6> + {% if has_temporal %} + <div class="alert alert-info mb-2"> + <i class="fas fa-info-circle"></i> This publication already has temporal extent data. You can update it if needed. + </div> + {% endif %} + <p class="text-muted small">Provide the time period this research data covers (not the publication date).</p> + <div class="form-row"> + <div class="form-group col-md-6"> + <label for="start-date">Start date</label> + <input type="text" class="form-control" id="start-date" placeholder="e.g., 2010, 2010-01, 2010-01-15"> + <small class="form-text text-muted">Formats: YYYY, YYYY-MM, or YYYY-MM-DD</small> + </div> + <div class="form-group col-md-6"> + <label for="end-date">End date</label> + <input type="text" class="form-control" id="end-date" placeholder="e.g., 2020, 2020-12, 2020-12-31"> + <small class="form-text text-muted">Formats: YYYY, YYYY-MM, or YYYY-MM-DD</small> + </div> + </div> + </div> + + <div class="text-center"> + <button type="submit" class="btn btn-primary btn-lg"> + <i class="fas fa-paper-plane"></i> Submit contribution + </button> + <p class="text-muted mt-2 small"> + {% if not has_geometry %} + You can submit spatial extent, temporal extent, or both together. + {% else %} + Submit temporal extent data for this publication. + {% endif %} + </p> + </div> + </form> + </div> +</div> +{% endif %} + <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script> + {% if can_contribute or is_admin %} + <!-- Leaflet Draw plugin --> + <script src="https://unpkg.com/leaflet-draw@1.0.4/dist/leaflet.draw.js" + crossorigin=""></script> + {% endif %} <!-- Leaflet Fullscreen plugin --> <script src="https://unpkg.com/leaflet.fullscreen@3.0.2/Control.FullScreen.js" crossorigin=""></script> <script> - const publicationFeature = {{ feature_json|safe }}; + const publicationFeature = {{ feature_json|default:"null"|safe }}; + const canContribute = {{ can_contribute|yesno:"true,false" }}; + const canPublish = {{ can_publish|yesno:"true,false" }}; + const canUnpublish = {{ can_unpublish|yesno:"true,false" }}; + const hasGeometry = {{ has_geometry|yesno:"true,false" }}; + const doi = "{{ pub.doi }}"; + const useIdUrls = {{ use_id_urls|default:"false"|yesno:"true,false" }}; + const pubId = {{ pub.id }}; + const map = L.map('mini-map', { scrollWheelZoom: true, fullscreenControl: true, @@ -73,20 +229,275 @@ <h1 class="page-title">{{ pub.title }}</h1> attribution: '© OpenStreetMap contributors' }).addTo(map); - const layer = L.geoJSON(publicationFeature, { - pointToLayer: (feat, latlng) => L.circleMarker(latlng, { radius: 6 }) - }).addTo(map); + // Display existing geometry if available + let layer = null; + if (publicationFeature) { + layer = L.geoJSON(publicationFeature, { + pointToLayer: (feat, latlng) => L.circleMarker(latlng, { radius: 6 }) + }).addTo(map); - const bounds = layer.getBounds ? layer.getBounds() : null; - if (bounds && bounds.isValid && bounds.isValid()) { - map.fitBounds(bounds.pad(0.2)); - } else if (publicationFeature.geometry && publicationFeature.geometry.type === "Point") { - const c = publicationFeature.geometry.coordinates; // [lng, lat] - map.setView([c[1], c[0]], 10); + const bounds = layer.getBounds ? layer.getBounds() : null; + if (bounds && bounds.isValid && bounds.isValid()) { + map.fitBounds(bounds.pad(0.2)); + } else if (publicationFeature.geometry && publicationFeature.geometry.type === "Point") { + const c = publicationFeature.geometry.coordinates; // [lng, lat] + map.setView([c[1], c[0]], 10); + } else { + map.setView([0, 0], 2); + } } else { map.setView([0, 0], 2); } + // Add drawing controls for contributions + let drawnItems = null; + if (canContribute) { + drawnItems = new L.FeatureGroup(); + map.addLayer(drawnItems); + + const drawControl = new L.Control.Draw({ + edit: { + featureGroup: drawnItems, + remove: true + }, + draw: { + polygon: true, + polyline: false, + rectangle: true, + circle: false, + circlemarker: false, + marker: true + } + }); + map.addControl(drawControl); + + // Handle drawing events + map.on(L.Draw.Event.CREATED, function (event) { + const layer = event.layer; + drawnItems.addLayer(layer); + }); + + // Update geometry status indicator when drawings change + map.on('draw:created', function(e) { + const geometryStatusText = document.getElementById('geometry-status-text'); + const geometryStatus = document.getElementById('geometry-status'); + if (geometryStatusText && geometryStatus) { + const count = drawnItems.getLayers().length + 1; + geometryStatusText.textContent = `${count} geometric feature(s) drawn. Ready to submit.`; + geometryStatus.classList.remove('alert-warning'); + geometryStatus.classList.add('alert-success'); + } + }); + + map.on('draw:deleted', function(e) { + const geometryStatusText = document.getElementById('geometry-status-text'); + const geometryStatus = document.getElementById('geometry-status'); + if (geometryStatusText && geometryStatus) { + const count = drawnItems.getLayers().length; + if (count === 0) { + geometryStatusText.textContent = 'No geometry drawn yet. Use the map tools above to add spatial extent.'; + geometryStatus.classList.remove('alert-success'); + geometryStatus.classList.add('alert-warning'); + } else { + geometryStatusText.textContent = `${count} geometric feature(s) drawn. Ready to submit.`; + } + } + }); + } + + // Unified contribution form handler + const contributionForm = document.getElementById('contribution-form'); + if (contributionForm) { + contributionForm.addEventListener('submit', function(e) { + e.preventDefault(); + + // Collect temporal extent data + const startDate = document.getElementById('start-date').value.trim(); + const endDate = document.getElementById('end-date').value.trim(); + + // Collect geometry data if applicable + let geometryCollection = null; + const needsGeometry = !hasGeometry; + + if (needsGeometry && drawnItems) { + const layers = drawnItems.getLayers(); + if (layers.length > 0) { + const geojson = drawnItems.toGeoJSON(); + const geometries = geojson.features.map(f => f.geometry); + geometryCollection = { + type: 'GeometryCollection', + geometries: geometries + }; + } + } + + // Validate that at least something is being contributed + if (!geometryCollection && !startDate && !endDate) { + if (needsGeometry) { + alert('Please provide at least spatial extent (draw on map) or temporal extent (enter dates).'); + } else { + alert('Please provide at least a start date or end date for temporal extent.'); + } + return; + } + + // Validate date formats if provided + const datePattern = /^\d{4}(-\d{2})?(-\d{2})?$/; + if (startDate && !datePattern.test(startDate)) { + alert('Invalid start date format. Please use YYYY, YYYY-MM, or YYYY-MM-DD.'); + return; + } + if (endDate && !datePattern.test(endDate)) { + alert('Invalid end date format. Please use YYYY, YYYY-MM, or YYYY-MM-DD.'); + return; + } + + // Build confirmation message + let confirmParts = []; + if (geometryCollection) confirmParts.push('spatial extent'); + if (startDate || endDate) confirmParts.push('temporal extent'); + const confirmMsg = `Are you sure you want to submit ${confirmParts.join(' and ')}? It will be reviewed by administrators.`; + + if (!confirm(confirmMsg)) { + return; + } + + // Get CSRF token + const csrfElement = document.querySelector('[name=csrfmiddlewaretoken]'); + if (!csrfElement) { + alert('CSRF token not found. Please refresh the page.'); + return; + } + const csrftoken = csrfElement.value; + + // Prepare submission data + const submissionData = {}; + if (geometryCollection) { + submissionData.geometry = geometryCollection; + } + if (startDate || endDate) { + submissionData.temporal_extent = {}; + if (startDate) submissionData.temporal_extent.start_date = startDate; + if (endDate) submissionData.temporal_extent.end_date = endDate; + } + + const url = useIdUrls ? `/work/${pubId}/contribute-geometry/` : `/work/${doi}/contribute-geometry/`; + console.log('Submitting contribution:', submissionData); + + // Submit contribution + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken + }, + body: JSON.stringify(submissionData) + }) + .then(response => { + if (!response.ok) { + return response.json().then(data => { + throw new Error(data.error || `HTTP error ${response.status}`); + }); + } + return response.json(); + }) + .then(data => { + if (data.success) { + alert(data.message); + location.reload(); + } else { + alert('Error: ' + (data.error || 'Unknown error')); + } + }) + .catch(error => { + console.error('Error:', error); + alert('Error: ' + error.message); + }); + }); + } + + // Add publish button handler for admins + if (canPublish) { + const publishBtn = document.getElementById('publish-btn'); + if (publishBtn) { + publishBtn.addEventListener('click', function() { + if (!confirm('Are you sure you want to publish this work? It will become publicly visible.')) { + return; + } + + const csrfElement = document.querySelector('[name=csrfmiddlewaretoken]'); + if (!csrfElement) { + alert('CSRF token not found. Please refresh the page.'); + return; + } + const csrftoken = csrfElement.value; + + const publishUrl = useIdUrls ? `/work/${pubId}/publish/` : `/work/${doi}/publish/`; + fetch(publishUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert(data.message); + location.reload(); + } else { + alert('Error: ' + (data.error || 'Unknown error')); + } + }) + .catch(error => { + console.error('Error:', error); + alert('An error occurred while publishing the work.'); + }); + }); + } + } + + // Add unpublish button handler for admins + if (canUnpublish) { + const unpublishBtn = document.getElementById('unpublish-btn'); + if (unpublishBtn) { + unpublishBtn.addEventListener('click', function() { + if (!confirm('Are you sure you want to unpublish this work? It will be set to Draft status and will no longer be publicly visible.')) { + return; + } + + const csrfElement = document.querySelector('[name=csrfmiddlewaretoken]'); + if (!csrfElement) { + alert('CSRF token not found. Please refresh the page.'); + return; + } + const csrftoken = csrfElement.value; + + const unpublishUrl = useIdUrls ? `/work/${pubId}/unpublish/` : `/work/${doi}/unpublish/`; + fetch(unpublishUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken + } + }) + .then(response => response.json()) + .then(data => { + if (data.success) { + alert(data.message); + location.reload(); + } else { + alert('Error: ' + (data.error || 'Unknown error')); + } + }) + .catch(error => { + console.error('Error:', error); + alert('An error occurred while unpublishing the work.'); + }); + }); + } + } + // Add custom "Zoom to All Features" button L.Control.ZoomAll = L.Control.extend({ onAdd: function(map) { @@ -113,7 +524,6 @@ <h1 class="page-title">{{ pub.title }}</h1> // Add the zoom to all button to the map new L.Control.ZoomAll({ position: 'topleft' }).addTo(map); </script> -{% endif %} <p class="muted"> <a href="/api/v1/publications/{{ pub.id }}/" target="_blank" rel="noopener">View raw JSON from API</a> diff --git a/publications/templates/works.html b/publications/templates/works.html index 4f6ee94..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 %} <ul class="nav navbar-nav"> {% if request.user.is_authenticated %} @@ -12,9 +15,26 @@ {% block content %} <div class="container py-5 works-page"> <h2>All Article Links</h2> + {% if is_admin %} + <p class="alert alert-info"> + <strong>Admin view:</strong> You can see all publications regardless of status. Status labels are shown next to each entry. + </p> + {% endif %} <ul> {% for item in links %} - <li><a href="{{ item.href }}" target="_blank" rel="noopener">{{ item.title }}</a></li> + <li> + <a href="{{ item.href }}" target="_blank" rel="noopener">{{ item.title }}</a> + {% if is_admin and item.status %} + <span class="badge + {% if item.status_code == 'p' %}badge-success + {% elif item.status_code == 'd' %}badge-secondary + {% elif item.status_code == 't' %}badge-warning + {% elif item.status_code == 'w' %}badge-danger + {% elif item.status_code == 'h' %}badge-info + {% elif item.status_code == 'c' %}badge-primary + {% endif %}">{{ item.status }}</span> + {% endif %} + </li> {% empty %} <li>No publications found.</li> {% endfor %} diff --git a/publications/urls.py b/publications/urls.py index 101978f..80d27fc 100644 --- a/publications/urls.py +++ b/publications/urls.py @@ -4,14 +4,14 @@ from django.urls import path, include from django.shortcuts import redirect from publications import views +from publications import views_geometry from .feeds import GeoFeed from django.views.generic import RedirectView from .feeds_geometry import GeoFeedByGeometry from django.urls import path -from . import views +from . import views from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView from publications.api import router as publications_router -from publications.api import router as publications_router app_name = "optimap" @@ -25,6 +25,15 @@ path('api/schema/ui/', SpectacularRedocView.as_view(url_name='optimap:schema'), name='redoc'), path('download/geojson/', views.download_geojson, name='download_geojson'), path("works/", views.works_list, name="works-list"), + # ID-based URLs (for publications without DOI) + path("work/<int:pub_id>/contribute-geometry/", views_geometry.contribute_geometry_by_id, name="contribute-geometry-by-id"), + path("work/<int:pub_id>/publish/", views_geometry.publish_work_by_id, name="publish-work-by-id"), + path("work/<int:pub_id>/unpublish/", views_geometry.unpublish_work_by_id, name="unpublish-work-by-id"), + path("work/<int:pub_id>/", views.work_landing_by_id, name="publication-by-id"), + # DOI-based URLs (primary method) + path("work/<path:doi>/contribute-geometry/", views_geometry.contribute_geometry, name="contribute-geometry"), + path("work/<path:doi>/publish/", views_geometry.publish_work, name="publish-work"), + path("work/<path:doi>/unpublish/", views_geometry.unpublish_work, name="unpublish-work"), path("work/<path:doi>/", views.work_landing, name="article-landing"), path('download/geopackage/', views.download_geopackage, name='download_geopackage'), path('favicon.ico', lambda request: redirect('static/favicon.ico', permanent=True)), @@ -56,5 +65,6 @@ GeoFeedByGeometry(feed_type_variant="georss"), name="feed-georss-by-slug",), path("feeds/geoatom/<slug:geometry_slug>/", GeoFeedByGeometry(feed_type_variant="geoatom"), name="feed-geoatom-by-slug"), + path('contribute/', views.contribute, name="contribute"), ] diff --git a/publications/views.py b/publications/views.py index 7698479..abaa62b 100644 --- a/publications/views.py +++ b/publications/views.py @@ -128,6 +128,34 @@ def download_geopackage(request): def main(request): return render(request, "main.html") +def contribute(request): + """ + Page showing harvested publications that need spatial or temporal extent contributions. + Displays publications with Harvested status that are missing geometry or temporal extent. + """ + from django.contrib.gis.geos import GeometryCollection + from django.db.models import Q + + # Get publications that are harvested and missing spatial OR temporal extent + publications_query = Publication.objects.filter( + status='h', # Harvested status + ).filter( + Q(geometry__isnull=True) | # NULL geometry + Q(geometry__isempty=True) | # Empty GeometryCollection + Q(timeperiod_startdate__isnull=True) | # NULL start date + Q(timeperiod_enddate__isnull=True) # NULL end date + ).order_by('-creationDate') + + total_count = publications_query.count() + # Limit to first 20 for performance (no pagination) + publications_needing_contribution = publications_query[:20] + + context = { + 'publications': publications_needing_contribution, + 'total_count': total_count, + } + return render(request, 'contribute.html', context) + def about(request): return render(request, 'about.html') @@ -613,23 +641,114 @@ def works_list(request): Public page that lists a link for every work: - DOI present -> /work/<doi> (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. + """ + 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: + feature = { + "type": "Feature", + "geometry": json.loads(pub.geometry.geojson), + "properties": {"title": pub.title, "doi": pub.doi}, + } + feature_json = json.dumps(feature) + + # Check if geometry is missing (NULL or empty) + has_geometry = pub.geometry and not pub.geometry.empty + + # Check if temporal extent is missing + has_temporal = bool(pub.timeperiod_startdate or pub.timeperiod_enddate) + + # Users can contribute if publication is harvested and missing either geometry or temporal extent + can_contribute = request.user.is_authenticated and pub.status == 'h' and (not has_geometry or not has_temporal) + + # Can publish if: Contributed status OR (Harvested with at least one extent type) + can_publish = is_admin and (pub.status == 'c' or (pub.status == 'h' and (has_geometry or has_temporal))) + can_unpublish = is_admin and pub.status == 'p' # Can unpublish published works + + context = { + "pub": pub, + "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, + "has_geometry": has_geometry, + "has_temporal": has_temporal, + "can_contribute": can_contribute, + "can_publish": can_publish, + "can_unpublish": can_unpublish, + "show_provenance": is_admin, + } + return render(request, "work_landing_page.html", context) + + +def work_landing_by_id(request, pub_id): """ - pub = get_object_or_404(Publication, doi=doi) + Landing page for a publication accessed by database ID. + Used for publications without a DOI. + + Only published works (status='p') are accessible to non-admin users. + Admin users can view all works with a status label. + """ + is_admin = request.user.is_authenticated and request.user.is_staff + + # Get the publication + try: + pub = Publication.objects.get(id=pub_id) + 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: @@ -640,10 +759,32 @@ def work_landing(request, doi): } feature_json = json.dumps(feature) + # Check if geometry is missing (NULL or empty) + has_geometry = pub.geometry and not pub.geometry.empty + + # Check if temporal extent is missing + has_temporal = bool(pub.timeperiod_startdate or pub.timeperiod_enddate) + + # Users can contribute if publication is harvested and missing either geometry or temporal extent + can_contribute = request.user.is_authenticated and pub.status == 'h' and (not has_geometry or not has_temporal) + + # Can publish if: Contributed status OR (Harvested with at least one extent type) + can_publish = is_admin and (pub.status == 'c' or (pub.status == 'h' and (has_geometry or has_temporal))) + can_unpublish = is_admin and pub.status == 'p' # Can unpublish published works + context = { "pub": pub, "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, + "has_geometry": has_geometry, + "has_temporal": has_temporal, + "can_contribute": can_contribute, + "can_publish": can_publish, + "can_unpublish": can_unpublish, + "show_provenance": is_admin, + "use_id_urls": True, # Flag to use ID-based URLs in template } return render(request, "work_landing_page.html", context) \ No newline at end of file diff --git a/publications/views_geometry.py b/publications/views_geometry.py new file mode 100644 index 0000000..91021fd --- /dev/null +++ b/publications/views_geometry.py @@ -0,0 +1,264 @@ +""" +Views for geometry contribution and publication management. +""" +import logging +import json +from django.contrib.auth.decorators import login_required +from django.contrib.admin.views.decorators import staff_member_required +from django.http import JsonResponse +from django.views.decorators.http import require_POST +from django.views.decorators.csrf import csrf_exempt +from django.contrib.gis.geos import GEOSGeometry +from django.utils import timezone +from publications.models import Publication + +logger = logging.getLogger(__name__) + + +# Core ID-based views (internal implementation) + +@require_POST +def contribute_geometry_by_id(request, pub_id): + """ + API endpoint for users to contribute geometry and/or temporal extent to a publication by ID. + Used for publications without a DOI. + Changes status from 'Harvested' to 'Contributed'. + """ + # Check authentication for AJAX requests + if not request.user.is_authenticated: + return JsonResponse({'error': 'Authentication required'}, status=401) + + try: + pub = Publication.objects.get(id=pub_id) + except Publication.DoesNotExist: + return JsonResponse({'error': 'Publication not found'}, status=404) + + # Only allow contributions to harvested publications + if pub.status != 'h': + return JsonResponse({ + 'error': 'Can only contribute to harvested publications' + }, status=400) + + try: + # Parse request data + data = json.loads(request.body) + geojson = data.get('geometry') + temporal_extent = data.get('temporal_extent') + + logger.info("Received contribution request for publication ID: %s, data: %s", pub_id, data) + + if not geojson and not temporal_extent: + logger.warning("No geometry or temporal extent provided in request") + return JsonResponse({'error': 'No geometry or temporal extent provided'}, status=400) + + # Build contribution note + old_provenance = pub.provenance or '' + contribution_parts = [] + changes_made = [] + + # Handle geometry contribution + if geojson: + # Check if geometry already exists + if pub.geometry and not pub.geometry.empty: + return JsonResponse({ + 'error': 'Publication already has geometry' + }, status=400) + + # Convert GeoJSON to GeometryCollection + logger.info("Converting geometry: %s", geojson) + geometry = GEOSGeometry(json.dumps(geojson)) + pub.geometry = geometry + changes_made.append(f"Changed geometry from empty to {geometry.geom_type}") + + # Handle temporal extent contribution + if temporal_extent: + start_date = temporal_extent.get('start_date') + end_date = temporal_extent.get('end_date') + + if start_date: + pub.timeperiod_startdate = [start_date] + changes_made.append(f"Set start date to {start_date}") + if end_date: + pub.timeperiod_enddate = [end_date] + changes_made.append(f"Set end date to {end_date}") + + # Create provenance note + contribution_note = ( + f"\n\nContribution by user {request.user.username} " + f"({request.user.email}) on {timezone.now().isoformat()}. " + + ". ".join(changes_made) + ". " + f"Status changed from Harvested to Contributed." + ) + + pub.status = 'c' # Contributed + pub.provenance = old_provenance + contribution_note + pub.save() + + logger.info( + "User %s contributed to publication %s (ID: %s): %s", + request.user.username, pub.title[:50], pub.id, ", ".join(changes_made) + ) + + return JsonResponse({ + 'success': True, + 'message': 'Thank you for your contribution! ' + 'An administrator will review your changes.' + }) + + except json.JSONDecodeError as e: + logger.error("JSON decode error: %s", str(e)) + return JsonResponse({'error': 'Invalid JSON'}, status=400) + except Exception as e: + logger.error("Error saving contribution: %s", str(e), exc_info=True) + return JsonResponse({'error': str(e)}, status=500) + + +@staff_member_required +@require_POST +def publish_work_by_id(request, pub_id): + """ + API endpoint for admins to publish a work by ID. + Used for publications without a DOI. + Changes status from 'Contributed' or 'Harvested' to 'Published'. + For harvested publications, requires that at least one extent (spatial or temporal) exists. + """ + try: + pub = Publication.objects.get(id=pub_id) + except Publication.DoesNotExist: + return JsonResponse({'error': 'Publication not found'}, status=404) + + # Check if publication has any extent information + has_geometry = pub.geometry and not pub.geometry.empty + has_temporal = bool(pub.timeperiod_startdate or pub.timeperiod_enddate) + + # Allow publishing of contributed publications or harvested publications with at least one extent + if pub.status == 'c': + # Contributed - can always publish + old_status = 'Contributed' + elif pub.status == 'h': + # Harvested - only if it has at least one extent type + if not (has_geometry or has_temporal): + return JsonResponse({ + 'error': 'Cannot publish harvested publication without spatial or temporal extent' + }, status=400) + old_status = 'Harvested' + else: + return JsonResponse({ + 'error': 'Can only publish contributed or harvested publications' + }, status=400) + + try: + # Update publication + old_provenance = pub.provenance or '' + publish_note = ( + f"\n\nPublished by admin {request.user.username} " + f"({request.user.email}) on {timezone.now().isoformat()}. " + f"Status changed from {old_status} to Published." + ) + + pub.status = 'p' # Published + pub.provenance = old_provenance + publish_note + pub.save() + + logger.info( + "Admin %s published %s publication %s (ID: %s)", + request.user.username, old_status.lower(), pub.title[:50], pub.id + ) + + return JsonResponse({ + 'success': True, + 'message': 'Publication is now public!' + }) + + except Exception as e: + logger.error("Error publishing work: %s", str(e)) + return JsonResponse({'error': str(e)}, status=500) + + +@staff_member_required +@require_POST +def unpublish_work_by_id(request, pub_id): + """ + API endpoint for admins to unpublish a work by ID. + Changes status from 'Published' to 'Draft'. + """ + try: + pub = Publication.objects.get(id=pub_id) + except Publication.DoesNotExist: + return JsonResponse({'error': 'Publication not found'}, status=404) + + # Only allow unpublishing of published works + if pub.status != 'p': + return JsonResponse({ + 'error': 'Can only unpublish published works' + }, status=400) + + try: + # Update publication + old_provenance = pub.provenance or '' + unpublish_note = ( + f"\n\nUnpublished by admin {request.user.username} " + f"({request.user.email}) on {timezone.now().isoformat()}. " + f"Status changed from Published to Draft." + ) + + pub.status = 'd' # Draft + pub.provenance = old_provenance + unpublish_note + pub.save() + + logger.info( + "Admin %s unpublished publication %s (ID: %s)", + request.user.username, pub.title[:50], pub.id + ) + + return JsonResponse({ + 'success': True, + 'message': 'Publication has been unpublished and set to draft status.' + }) + + except Exception as e: + logger.error("Error unpublishing work: %s", str(e)) + return JsonResponse({'error': str(e)}, status=500) + + +# DOI-based views (wrappers that translate DOI to ID) + +@require_POST +def contribute_geometry(request, doi): + """ + API endpoint for users to contribute geometry to a publication by DOI. + Wrapper that translates DOI to ID and delegates to contribute_geometry_by_id. + """ + try: + pub = Publication.objects.get(doi=doi) + return contribute_geometry_by_id(request, pub.id) + except Publication.DoesNotExist: + return JsonResponse({'error': 'Publication not found'}, status=404) + + +@staff_member_required +@require_POST +def publish_work(request, doi): + """ + API endpoint for admins to publish a work by DOI. + Wrapper that translates DOI to ID and delegates to publish_work_by_id. + """ + try: + pub = Publication.objects.get(doi=doi) + return publish_work_by_id(request, pub.id) + except Publication.DoesNotExist: + return JsonResponse({'error': 'Publication not found'}, status=404) + + +@staff_member_required +@require_POST +def unpublish_work(request, doi): + """ + API endpoint for admins to unpublish a work by DOI. + Wrapper that translates DOI to ID and delegates to unpublish_work_by_id. + """ + try: + pub = Publication.objects.get(doi=doi) + return unpublish_work_by_id(request, pub.id) + except Publication.DoesNotExist: + return JsonResponse({'error': 'Publication not found'}, status=404) 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_geometry_contribution.py b/tests/test_geometry_contribution.py new file mode 100644 index 0000000..fd4b824 --- /dev/null +++ b/tests/test_geometry_contribution.py @@ -0,0 +1,563 @@ +"""Tests for geometry contribution and publication workflow.""" +import json +from django.test import TestCase, Client +from django.contrib.gis.geos import Point, GeometryCollection +from django.utils import timezone +from publications.models import Publication, Source +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class GeometryContributionTests(TestCase): + """Test geometry contribution API endpoint.""" + + def setUp(self): + """Set up test data.""" + self.client = Client() + + # Create users + self.regular_user = User.objects.create_user( + username='contributor@example.com', + email='contributor@example.com', + password='testpass123' + ) + + self.admin_user = User.objects.create_user( + username='admin@example.com', + email='admin@example.com', + password='adminpass123', + is_staff=True, + is_superuser=True + ) + + # Create source + self.source = Source.objects.create( + name="Test Journal", + url_field="https://example.com/oai", + homepage_url="https://example.com/journal" + ) + + # Create harvested publication without geometry + self.pub_harvested = Publication.objects.create( + title="Harvested Publication Without Geometry", + abstract="This needs geolocation", + url="https://example.com/article1", + doi="10.1234/harvested", + status="h", # Harvested + publicationDate=timezone.now().date(), + geometry=GeometryCollection(), # Empty geometry + source=self.source, + provenance="Harvested via OAI-PMH on 2025-01-01." + ) + + # Create harvested publication with existing geometry + self.pub_with_geometry = Publication.objects.create( + title="Publication With Geometry", + abstract="This already has location", + url="https://example.com/article2", + doi="10.1234/withgeo", + status="h", + publicationDate=timezone.now().date(), + geometry=GeometryCollection(Point(12.4924, 41.8902)), + source=self.source + ) + + # Create published publication + self.pub_published = Publication.objects.create( + title="Published Publication", + abstract="This is published", + url="https://example.com/article3", + doi="10.1234/published", + status="p", # Published + publicationDate=timezone.now().date(), + geometry=GeometryCollection(), + source=self.source + ) + + # Sample geometry for contributions + self.test_geometry = { + "type": "GeometryCollection", + "geometries": [ + { + "type": "Point", + "coordinates": [13.4050, 52.5200] + } + ] + } + + def test_contribute_geometry_requires_authentication(self): + """Test that contribution requires authentication.""" + url = f'/work/{self.pub_harvested.doi}/contribute-geometry/' + response = self.client.post( + url, + data=json.dumps({'geometry': self.test_geometry}), + content_type='application/json' + ) + self.assertEqual(response.status_code, 401) + data = response.json() + self.assertEqual(data['error'], 'Authentication required') + + def test_contribute_geometry_success(self): + """Test successful geometry contribution.""" + self.client.login(username='contributor@example.com', password='testpass123') + + url = f'/work/{self.pub_harvested.doi}/contribute-geometry/' + response = self.client.post( + url, + data=json.dumps({'geometry': self.test_geometry}), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + self.assertIn('Thank you for your contribution', data['message']) + + # Verify database changes + self.pub_harvested.refresh_from_db() + self.assertEqual(self.pub_harvested.status, 'c') # Contributed + self.assertFalse(self.pub_harvested.geometry.empty) + + # Verify provenance + self.assertIn('contributor@example.com', self.pub_harvested.provenance) + self.assertIn('Contribution by user', self.pub_harvested.provenance) + self.assertIn('Status changed from Harvested to Contributed', self.pub_harvested.provenance) + + def test_contribute_geometry_publication_not_found(self): + """Test contribution to non-existent publication.""" + self.client.login(username='contributor@example.com', password='testpass123') + + url = '/work/10.1234/nonexistent/contribute-geometry/' + response = self.client.post( + url, + data=json.dumps({'geometry': self.test_geometry}), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 404) + data = response.json() + self.assertEqual(data['error'], 'Publication not found') + + def test_contribute_geometry_wrong_status(self): + """Test that only harvested publications can receive contributions.""" + self.client.login(username='contributor@example.com', password='testpass123') + + url = f'/work/{self.pub_published.doi}/contribute-geometry/' + response = self.client.post( + url, + data=json.dumps({'geometry': self.test_geometry}), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertEqual(data['error'], 'Can only contribute to harvested publications') + + def test_contribute_geometry_already_has_geometry(self): + """Test that publications with geometry cannot receive new contributions.""" + self.client.login(username='contributor@example.com', password='testpass123') + + url = f'/work/{self.pub_with_geometry.doi}/contribute-geometry/' + response = self.client.post( + url, + data=json.dumps({'geometry': self.test_geometry}), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertEqual(data['error'], 'Publication already has geometry') + + def test_contribute_geometry_no_geometry_provided(self): + """Test error when no geometry is provided.""" + self.client.login(username='contributor@example.com', password='testpass123') + + url = f'/work/{self.pub_harvested.doi}/contribute-geometry/' + response = self.client.post( + url, + data=json.dumps({}), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertEqual(data['error'], 'No geometry or temporal extent provided') + + def test_contribute_geometry_invalid_json(self): + """Test error when invalid JSON is sent.""" + self.client.login(username='contributor@example.com', password='testpass123') + + url = f'/work/{self.pub_harvested.doi}/contribute-geometry/' + response = self.client.post( + url, + data='invalid json', + content_type='application/json' + ) + + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertEqual(data['error'], 'Invalid JSON') + + def test_contribute_geometry_polygon(self): + """Test contribution with polygon geometry.""" + self.client.login(username='contributor@example.com', password='testpass123') + + polygon_geometry = { + "type": "GeometryCollection", + "geometries": [ + { + "type": "Polygon", + "coordinates": [[ + [13.0, 52.0], + [14.0, 52.0], + [14.0, 53.0], + [13.0, 53.0], + [13.0, 52.0] + ]] + } + ] + } + + url = f'/work/{self.pub_harvested.doi}/contribute-geometry/' + response = self.client.post( + url, + data=json.dumps({'geometry': polygon_geometry}), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + + self.pub_harvested.refresh_from_db() + self.assertEqual(self.pub_harvested.status, 'c') + self.assertFalse(self.pub_harvested.geometry.empty) + + +class PublishWorkTests(TestCase): + """Test publish work API endpoint.""" + + def setUp(self): + """Set up test data.""" + self.client = Client() + + # Create users + self.regular_user = User.objects.create_user( + username='user@example.com', + email='user@example.com', + password='testpass123' + ) + + self.admin_user = User.objects.create_user( + username='admin@example.com', + email='admin@example.com', + password='adminpass123', + is_staff=True, + is_superuser=True + ) + + # Create source + self.source = Source.objects.create( + name="Test Journal", + url_field="https://example.com/oai" + ) + + # Create contributed publication + self.pub_contributed = Publication.objects.create( + title="Contributed Publication", + abstract="User contributed location", + url="https://example.com/article1", + doi="10.1234/contributed", + status="c", # Contributed + publicationDate=timezone.now().date(), + geometry=GeometryCollection(Point(13.4050, 52.5200)), + source=self.source, + provenance="Geometry contributed by user@example.com on 2025-01-01." + ) + + # Create harvested publication + self.pub_harvested = Publication.objects.create( + title="Harvested Publication", + abstract="Not yet contributed", + url="https://example.com/article2", + doi="10.1234/harvested", + status="h", # Harvested + publicationDate=timezone.now().date(), + geometry=GeometryCollection(), + source=self.source + ) + + def test_publish_requires_admin(self): + """Test that publishing requires admin privileges.""" + self.client.login(username='user@example.com', password='testpass123') + + url = f'/work/{self.pub_contributed.doi}/publish/' + response = self.client.post(url, content_type='application/json') + + # staff_member_required redirects non-staff users + self.assertEqual(response.status_code, 302) + + def test_publish_success(self): + """Test successful publication.""" + self.client.login(username='admin@example.com', password='adminpass123') + + url = f'/work/{self.pub_contributed.doi}/publish/' + response = self.client.post(url, content_type='application/json') + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + self.assertIn('Publication is now public', data['message']) + + # Verify database changes + self.pub_contributed.refresh_from_db() + self.assertEqual(self.pub_contributed.status, 'p') # Published + + # Verify provenance + self.assertIn('admin@example.com', self.pub_contributed.provenance) + self.assertIn('Published by admin', self.pub_contributed.provenance) + self.assertIn('Status changed from Contributed to Published', self.pub_contributed.provenance) + + def test_publish_publication_not_found(self): + """Test publishing non-existent publication.""" + self.client.login(username='admin@example.com', password='adminpass123') + + url = '/work/10.1234/nonexistent/publish/' + response = self.client.post(url, content_type='application/json') + + self.assertEqual(response.status_code, 404) + data = response.json() + self.assertEqual(data['error'], 'Publication not found') + + def test_publish_wrong_status(self): + """Test that harvested publications without geometry cannot be published.""" + self.client.login(username='admin@example.com', password='adminpass123') + + url = f'/work/{self.pub_harvested.doi}/publish/' + response = self.client.post(url, content_type='application/json') + + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertEqual(data['error'], 'Cannot publish harvested publication without spatial or temporal extent') + + def test_publish_harvested_with_geometry(self): + """Test that harvested publications with geometry can be published.""" + # Create a harvested publication with geometry + from django.contrib.gis.geos import Point, GeometryCollection + pub_harvested_with_geo = Publication.objects.create( + title='Harvested with Geometry', + status='h', + doi='10.1234/harvested-geo', + geometry=GeometryCollection(Point(13.405, 52.52)), + source=self.source + ) + + self.client.login(username='admin@example.com', password='adminpass123') + + url = f'/work/{pub_harvested_with_geo.doi}/publish/' + response = self.client.post(url, content_type='application/json') + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + + # Verify database changes + pub_harvested_with_geo.refresh_from_db() + self.assertEqual(pub_harvested_with_geo.status, 'p') # Published + self.assertIn('Harvested', pub_harvested_with_geo.provenance) + self.assertIn('Published by admin', pub_harvested_with_geo.provenance) + + +class WorkflowIntegrationTests(TestCase): + """Test the complete contribution and publication workflow.""" + + def setUp(self): + """Set up test data.""" + self.client = Client() + + # Create users + self.contributor = User.objects.create_user( + username='contributor@example.com', + email='contributor@example.com', + password='testpass123' + ) + + self.admin = User.objects.create_user( + username='admin@example.com', + email='admin@example.com', + password='adminpass123', + is_staff=True, + is_superuser=True + ) + + # Create source + self.source = Source.objects.create( + name="Test Journal", + url_field="https://example.com/oai" + ) + + # Create harvested publication + self.publication = Publication.objects.create( + title="Test Publication", + abstract="Test abstract", + url="https://example.com/article", + doi="10.1234/test", + status="h", # Harvested + publicationDate=timezone.now().date(), + geometry=GeometryCollection(), + source=self.source, + provenance="Harvested via OAI-PMH." + ) + + self.test_geometry = { + "type": "GeometryCollection", + "geometries": [ + { + "type": "Point", + "coordinates": [13.4050, 52.5200] + } + ] + } + + def test_complete_workflow(self): + """Test complete workflow: harvest -> contribute -> publish.""" + # Step 1: Verify initial state + self.assertEqual(self.publication.status, 'h') + self.assertTrue(self.publication.geometry.empty) + + # Step 2: User contributes geometry + self.client.login(username='contributor@example.com', password='testpass123') + contribute_url = f'/work/{self.publication.doi}/contribute-geometry/' + response = self.client.post( + contribute_url, + data=json.dumps({'geometry': self.test_geometry}), + content_type='application/json' + ) + self.assertEqual(response.status_code, 200) + + # Verify contribution + self.publication.refresh_from_db() + self.assertEqual(self.publication.status, 'c') # Contributed + self.assertFalse(self.publication.geometry.empty) + self.assertIn('contributor@example.com', self.publication.provenance) + + # Step 3: Admin publishes the contribution + self.client.login(username='admin@example.com', password='adminpass123') + publish_url = f'/work/{self.publication.doi}/publish/' + response = self.client.post(publish_url, content_type='application/json') + self.assertEqual(response.status_code, 200) + + # Verify publication + self.publication.refresh_from_db() + self.assertEqual(self.publication.status, 'p') # Published + self.assertIn('admin@example.com', self.publication.provenance) + + # Verify complete provenance trail + provenance = self.publication.provenance + self.assertIn('Harvested via OAI-PMH', provenance) + self.assertIn('Contribution by user contributor@example.com', provenance) + self.assertIn('Status changed from Harvested to Contributed', provenance) + self.assertIn('Published by admin admin@example.com', provenance) + self.assertIn('Status changed from Contributed to Published', provenance) + + +class UnpublishWorkTests(TestCase): + """Test unpublish work API endpoint.""" + + def setUp(self): + """Set up test data.""" + self.client = Client() + + # Create source + self.source = Source.objects.create( + name='Test Source', + is_oa=True, + is_preprint=False + ) + + # Create users + self.regular_user = User.objects.create_user( + username='user@example.com', + email='user@example.com', + password='testpass123' + ) + + self.admin_user = User.objects.create_user( + username='admin@example.com', + email='admin@example.com', + password='adminpass123', + is_staff=True, + is_superuser=True + ) + + # Create published publication + self.pub_published = Publication.objects.create( + title='Published Publication', + status='p', # Published + doi='10.1234/published', + geometry=GeometryCollection(Point(13.405, 52.52)), + source=self.source + ) + + # Create contributed publication (not yet published) + self.pub_contributed = Publication.objects.create( + title='Contributed Publication', + status='c', # Contributed + doi='10.1234/contributed', + geometry=GeometryCollection(Point(13.405, 52.52)), + source=self.source + ) + + def test_unpublish_requires_admin(self): + """Test that unpublishing requires admin privileges.""" + self.client.login(username='user@example.com', password='testpass123') + + url = f'/work/{self.pub_published.doi}/unpublish/' + response = self.client.post(url, content_type='application/json') + + # staff_member_required redirects non-staff users + self.assertEqual(response.status_code, 302) + + def test_unpublish_success(self): + """Test successful unpublishing.""" + self.client.login(username='admin@example.com', password='adminpass123') + + url = f'/work/{self.pub_published.doi}/unpublish/' + response = self.client.post(url, content_type='application/json') + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + self.assertIn('unpublished', data['message'].lower()) + + # Verify database changes + self.pub_published.refresh_from_db() + self.assertEqual(self.pub_published.status, 'd') # Draft + + # Verify provenance + self.assertIn('admin@example.com', self.pub_published.provenance) + self.assertIn('Unpublished by admin', self.pub_published.provenance) + self.assertIn('Status changed from Published to Draft', self.pub_published.provenance) + + def test_unpublish_wrong_status(self): + """Test that only published publications can be unpublished.""" + self.client.login(username='admin@example.com', password='adminpass123') + + url = f'/work/{self.pub_contributed.doi}/unpublish/' + response = self.client.post(url, content_type='application/json') + + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertEqual(data['error'], 'Can only unpublish published works') + + def test_unpublish_publication_not_found(self): + """Test unpublishing non-existent publication.""" + self.client.login(username='admin@example.com', password='adminpass123') + + url = '/work/10.1234/nonexistent/unpublish/' + response = self.client.post(url, content_type='application/json') + + self.assertEqual(response.status_code, 404) + data = response.json() + self.assertEqual(data['error'], 'Publication not found') diff --git a/tests/test_geometry_contribution_by_id.py b/tests/test_geometry_contribution_by_id.py new file mode 100644 index 0000000..612d5dd --- /dev/null +++ b/tests/test_geometry_contribution_by_id.py @@ -0,0 +1,204 @@ +"""Tests for ID-based geometry contribution (publications without DOI).""" +import json +from django.test import TestCase, Client +from django.contrib.gis.geos import Point, GeometryCollection +from django.utils import timezone +from publications.models import Publication, Source +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class GeometryContributionByIdTests(TestCase): + """Test ID-based geometry contribution API endpoint for publications without DOI.""" + + def setUp(self): + # Create source + self.source = Source.objects.create( + name='Test Source', + is_oa=True, + is_preprint=False + ) + + # Create users + self.contributor = User.objects.create_user( + username='contributor@example.com', + email='contributor@example.com', + password='testpass123' + ) + + self.admin_user = User.objects.create_user( + username='admin@example.com', + email='admin@example.com', + password='adminpass123', + is_staff=True, + is_superuser=True + ) + + # Create test publication WITHOUT DOI (harvested, no geometry) + self.pub_without_doi = Publication.objects.create( + title='Publication Without DOI', + status='h', # Harvested + doi=None, # No DOI + url='http://repository.example.org/id/12345', + geometry=GeometryCollection(), + source=self.source + ) + + # Create test publication with contributed geometry but no DOI + self.pub_contributed_no_doi = Publication.objects.create( + title='Contributed Publication Without DOI', + status='c', # Contributed + doi=None, + url='http://repository.example.org/id/67890', + geometry=GeometryCollection(Point(13.405, 52.52)), + source=self.source + ) + + self.test_geometry = { + "type": "GeometryCollection", + "geometries": [ + { + "type": "Point", + "coordinates": [13.405, 52.52] + } + ] + } + + def test_contribute_geometry_by_id_success(self): + """Test successful geometry contribution using publication ID.""" + self.client.login(username='contributor@example.com', password='testpass123') + + url = f'/work/{self.pub_without_doi.id}/contribute-geometry/' + response = self.client.post( + url, + data=json.dumps({'geometry': self.test_geometry}), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + + # Verify database changes + self.pub_without_doi.refresh_from_db() + self.assertEqual(self.pub_without_doi.status, 'c') # Contributed + self.assertFalse(self.pub_without_doi.geometry.empty) + + # Verify provenance + self.assertIn('contributor@example.com', self.pub_without_doi.provenance) + self.assertIn('Contribution by user', self.pub_without_doi.provenance) + + def test_contribute_geometry_by_id_requires_authentication(self): + """Test that contribution by ID requires authentication.""" + url = f'/work/{self.pub_without_doi.id}/contribute-geometry/' + response = self.client.post( + url, + data=json.dumps({'geometry': self.test_geometry}), + content_type='application/json' + ) + self.assertEqual(response.status_code, 401) + + def test_publish_work_by_id_success(self): + """Test successful publishing using publication ID.""" + self.client.login(username='admin@example.com', password='adminpass123') + + url = f'/work/{self.pub_contributed_no_doi.id}/publish/' + response = self.client.post(url, content_type='application/json') + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + + # Verify database changes + self.pub_contributed_no_doi.refresh_from_db() + self.assertEqual(self.pub_contributed_no_doi.status, 'p') # Published + + # Verify provenance + self.assertIn('admin@example.com', self.pub_contributed_no_doi.provenance) + self.assertIn('Published by admin', self.pub_contributed_no_doi.provenance) + + def test_work_landing_by_id_accessible(self): + """Test that publication landing page is accessible by ID.""" + # Make publication published so it's accessible + self.pub_without_doi.status = 'p' + self.pub_without_doi.save() + + url = f'/work/{self.pub_without_doi.id}/' + response = self.client.get(url) + + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.pub_without_doi.title) + # Check that ID URLs flag is set in JavaScript + self.assertContains(response, 'const useIdUrls = true') + # Check that publication ID is available in JavaScript + self.assertContains(response, f'const pubId = {self.pub_without_doi.id}') + + +class MixedDOIAndIDTests(TestCase): + """Test that both DOI-based and ID-based URLs work correctly.""" + + def setUp(self): + self.source = Source.objects.create( + name='Test Source', + is_oa=True, + is_preprint=False + ) + + self.user = User.objects.create_user( + username='user@example.com', + email='user@example.com', + password='testpass123' + ) + + # Publication with DOI + self.pub_with_doi = Publication.objects.create( + title='Publication With DOI', + status='h', + doi='10.5555/test123', + geometry=GeometryCollection(), + source=self.source + ) + + # Publication without DOI + self.pub_without_doi = Publication.objects.create( + title='Publication Without DOI', + status='h', + doi=None, + url='http://example.org/123', + geometry=GeometryCollection(), + source=self.source + ) + + self.test_geometry = { + "type": "GeometryCollection", + "geometries": [{"type": "Point", "coordinates": [13.405, 52.52]}] + } + + def test_both_url_types_work(self): + """Test that both DOI-based and ID-based contribution URLs work.""" + self.client.login(username='user@example.com', password='testpass123') + + # Test DOI-based URL + doi_url = f'/work/{self.pub_with_doi.doi}/contribute-geometry/' + response1 = self.client.post( + doi_url, + data=json.dumps({'geometry': self.test_geometry}), + content_type='application/json' + ) + self.assertEqual(response1.status_code, 200) + + # Test ID-based URL + id_url = f'/work/{self.pub_without_doi.id}/contribute-geometry/' + response2 = self.client.post( + id_url, + data=json.dumps({'geometry': self.test_geometry}), + content_type='application/json' + ) + self.assertEqual(response2.status_code, 200) + + # Verify both publications were updated + self.pub_with_doi.refresh_from_db() + self.pub_without_doi.refresh_from_db() + self.assertEqual(self.pub_with_doi.status, 'c') + self.assertEqual(self.pub_without_doi.status, 'c') 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) diff --git a/tests/test_status_workflow.py b/tests/test_status_workflow.py new file mode 100644 index 0000000..63c17f2 --- /dev/null +++ b/tests/test_status_workflow.py @@ -0,0 +1,216 @@ +"""Tests to verify publication status workflow compliance.""" +from django.test import TestCase +from django.contrib.gis.geos import Point, GeometryCollection +from publications.models import Publication, Source, STATUS_CHOICES +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class StatusWorkflowComplianceTests(TestCase): + """Verify all statuses are properly defined and workflow is enforced.""" + + def setUp(self): + self.source = Source.objects.create( + name='Test Source', + is_oa=True, + is_preprint=False + ) + + self.user = User.objects.create_user( + username='user@example.com', + email='user@example.com', + password='testpass123' + ) + + self.admin = User.objects.create_user( + username='admin@example.com', + email='admin@example.com', + password='adminpass123', + is_staff=True, + is_superuser=True + ) + + def test_all_six_statuses_defined(self): + """Verify all 6 statuses from README are defined in model.""" + expected_statuses = { + 'd': 'Draft', + 'h': 'Harvested', + 'c': 'Contributed', + 'p': 'Published', + 't': 'Testing', + 'w': 'Withdrawn' + } + + actual_statuses = dict(STATUS_CHOICES) + self.assertEqual(actual_statuses, expected_statuses) + self.assertEqual(len(STATUS_CHOICES), 6) + + def test_harvested_status_visibility(self): + """Harvested publications should not be visible to non-admin users.""" + pub = Publication.objects.create( + title='Harvested Publication', + status='h', + doi='10.1234/harvested', + source=self.source + ) + + # Non-admin cannot access + response = self.client.get(f'/work/{pub.doi}/') + self.assertEqual(response.status_code, 404) + + # Admin can access + self.client.login(username='admin@example.com', password='adminpass123') + response = self.client.get(f'/work/{pub.doi}/') + self.assertEqual(response.status_code, 200) + + def test_contributed_status_visibility(self): + """Contributed publications should not be visible to non-admin users.""" + pub = Publication.objects.create( + title='Contributed Publication', + status='c', + doi='10.1234/contributed', + source=self.source, + geometry=GeometryCollection(Point(13.405, 52.52)) + ) + + # Non-admin cannot access + response = self.client.get(f'/work/{pub.doi}/') + self.assertEqual(response.status_code, 404) + + # Admin can access + self.client.login(username='admin@example.com', password='adminpass123') + response = self.client.get(f'/work/{pub.doi}/') + self.assertEqual(response.status_code, 200) + + def test_published_status_visibility(self): + """Published publications should be visible to all users.""" + pub = Publication.objects.create( + title='Published Publication', + status='p', + doi='10.1234/published', + source=self.source, + geometry=GeometryCollection(Point(13.405, 52.52)) + ) + + # Non-admin can access + response = self.client.get(f'/work/{pub.doi}/') + self.assertEqual(response.status_code, 200) + + def test_draft_status_visibility(self): + """Draft publications should not be visible to non-admin users.""" + pub = Publication.objects.create( + title='Draft Publication', + status='d', + doi='10.1234/draft', + source=self.source + ) + + # Non-admin cannot access + response = self.client.get(f'/work/{pub.doi}/') + self.assertEqual(response.status_code, 404) + + # Admin can access + self.client.login(username='admin@example.com', password='adminpass123') + response = self.client.get(f'/work/{pub.doi}/') + self.assertEqual(response.status_code, 200) + + def test_testing_status_visibility(self): + """Testing publications should not be visible to non-admin users.""" + pub = Publication.objects.create( + title='Testing Publication', + status='t', + doi='10.1234/testing', + source=self.source + ) + + # Non-admin cannot access + response = self.client.get(f'/work/{pub.doi}/') + self.assertEqual(response.status_code, 404) + + # Admin can access + self.client.login(username='admin@example.com', password='adminpass123') + response = self.client.get(f'/work/{pub.doi}/') + self.assertEqual(response.status_code, 200) + + def test_withdrawn_status_visibility(self): + """Withdrawn publications should not be visible to non-admin users.""" + pub = Publication.objects.create( + title='Withdrawn Publication', + status='w', + doi='10.1234/withdrawn', + source=self.source + ) + + # Non-admin cannot access + response = self.client.get(f'/work/{pub.doi}/') + self.assertEqual(response.status_code, 404) + + # Admin can access + self.client.login(username='admin@example.com', password='adminpass123') + response = self.client.get(f'/work/{pub.doi}/') + self.assertEqual(response.status_code, 200) + + def test_contribution_only_allowed_for_harvested(self): + """Users can only contribute to harvested publications.""" + self.client.login(username='user@example.com', password='testpass123') + + # Test each non-harvested status + for status_code, status_name in [('d', 'Draft'), ('p', 'Published'), + ('t', 'Testing'), ('w', 'Withdrawn'), + ('c', 'Contributed')]: + pub = Publication.objects.create( + title=f'{status_name} Publication', + status=status_code, + doi=f'10.1234/{status_code}', + source=self.source + ) + + response = self.client.post( + f'/work/{pub.doi}/contribute-geometry/', + data='{"temporal_extent": {"start_date": "2020"}}', + content_type='application/json' + ) + + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertIn('Can only contribute to harvested publications', data['error']) + + def test_api_only_returns_published_for_non_admin(self): + """API should only return published publications to non-admin users.""" + # Create one of each status + for status_code, status_name in STATUS_CHOICES: + Publication.objects.create( + title=f'{status_name} Publication', + status=status_code, + doi=f'10.1234/{status_code}', + source=self.source, + geometry=GeometryCollection(Point(13.405, 52.52)) + ) + + # Non-admin request + response = self.client.get('/api/v1/publications/') + self.assertEqual(response.status_code, 200) + data = response.json() + + # Should only return published publications + self.assertEqual(data['count'], 1) + self.assertEqual(data['results']['features'][0]['properties']['doi'], '10.1234/p') + + def test_unpublish_creates_draft_status(self): + """Unpublishing should change status from Published to Draft.""" + pub = Publication.objects.create( + title='Published Publication', + status='p', + doi='10.1234/published', + source=self.source, + geometry=GeometryCollection(Point(13.405, 52.52)) + ) + + self.client.login(username='admin@example.com', password='adminpass123') + response = self.client.post(f'/work/{pub.doi}/unpublish/', content_type='application/json') + + self.assertEqual(response.status_code, 200) + pub.refresh_from_db() + self.assertEqual(pub.status, 'd') # Draft + self.assertIn('Status changed from Published to Draft', pub.provenance) diff --git a/tests/test_temporal_contribution.py b/tests/test_temporal_contribution.py new file mode 100644 index 0000000..2143448 --- /dev/null +++ b/tests/test_temporal_contribution.py @@ -0,0 +1,300 @@ +"""Tests for temporal extent contribution functionality.""" +import json +from django.test import TestCase, Client +from django.contrib.gis.geos import Point, GeometryCollection +from publications.models import Publication, Source +from django.contrib.auth import get_user_model + +User = get_user_model() + + +class TemporalExtentContributionTests(TestCase): + """Test temporal extent contribution API endpoint.""" + + def setUp(self): + # Create source + self.source = Source.objects.create( + name='Test Source', + is_oa=True, + is_preprint=False + ) + + # Create users + self.contributor = User.objects.create_user( + username='contributor@example.com', + email='contributor@example.com', + password='testpass123' + ) + + self.admin_user = User.objects.create_user( + username='admin@example.com', + email='admin@example.com', + password='adminpass123', + is_staff=True, + is_superuser=True + ) + + # Create test publication WITHOUT temporal extent + self.pub_without_temporal = Publication.objects.create( + title='Publication Without Temporal Extent', + status='h', # Harvested + doi='10.1234/no-temporal', + geometry=GeometryCollection(), + source=self.source, + timeperiod_startdate=None, + timeperiod_enddate=None + ) + + def test_contribute_temporal_extent_success(self): + """Test successful temporal extent contribution.""" + self.client.login(username='contributor@example.com', password='testpass123') + + url = f'/work/{self.pub_without_temporal.doi}/contribute-geometry/' + temporal_data = { + 'start_date': '2010', + 'end_date': '2020' + } + response = self.client.post( + url, + data=json.dumps({'temporal_extent': temporal_data}), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + + # Verify database changes + self.pub_without_temporal.refresh_from_db() + self.assertEqual(self.pub_without_temporal.status, 'c') # Contributed + self.assertEqual(self.pub_without_temporal.timeperiod_startdate, ['2010']) + self.assertEqual(self.pub_without_temporal.timeperiod_enddate, ['2020']) + + # Verify provenance + self.assertIn('contributor@example.com', self.pub_without_temporal.provenance) + self.assertIn('Set start date to 2010', self.pub_without_temporal.provenance) + self.assertIn('Set end date to 2020', self.pub_without_temporal.provenance) + + def test_contribute_only_start_date(self): + """Test contributing only start date.""" + self.client.login(username='contributor@example.com', password='testpass123') + + url = f'/work/{self.pub_without_temporal.doi}/contribute-geometry/' + temporal_data = {'start_date': '2015-06'} + response = self.client.post( + url, + data=json.dumps({'temporal_extent': temporal_data}), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 200) + self.pub_without_temporal.refresh_from_db() + self.assertEqual(self.pub_without_temporal.timeperiod_startdate, ['2015-06']) + self.assertIsNone(self.pub_without_temporal.timeperiod_enddate) + + def test_contribute_only_end_date(self): + """Test contributing only end date.""" + self.client.login(username='contributor@example.com', password='testpass123') + + url = f'/work/{self.pub_without_temporal.doi}/contribute-geometry/' + temporal_data = {'end_date': '2020-12-31'} + response = self.client.post( + url, + data=json.dumps({'temporal_extent': temporal_data}), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 200) + self.pub_without_temporal.refresh_from_db() + self.assertIsNone(self.pub_without_temporal.timeperiod_startdate) + self.assertEqual(self.pub_without_temporal.timeperiod_enddate, ['2020-12-31']) + + def test_contribute_both_geometry_and_temporal(self): + """Test contributing both geometry and temporal extent in one request.""" + self.client.login(username='contributor@example.com', password='testpass123') + + url = f'/work/{self.pub_without_temporal.doi}/contribute-geometry/' + data = { + 'geometry': { + 'type': 'GeometryCollection', + 'geometries': [ + {'type': 'Point', 'coordinates': [13.405, 52.52]} + ] + }, + 'temporal_extent': { + 'start_date': '2010', + 'end_date': '2020' + } + } + response = self.client.post( + url, + data=json.dumps(data), + content_type='application/json' + ) + + self.assertEqual(response.status_code, 200) + self.pub_without_temporal.refresh_from_db() + + # Verify both geometry and temporal extent were set + self.assertFalse(self.pub_without_temporal.geometry.empty) + self.assertEqual(self.pub_without_temporal.timeperiod_startdate, ['2010']) + self.assertEqual(self.pub_without_temporal.timeperiod_enddate, ['2020']) + self.assertEqual(self.pub_without_temporal.status, 'c') + + def test_contribute_temporal_requires_authentication(self): + """Test that temporal contribution requires authentication.""" + url = f'/work/{self.pub_without_temporal.doi}/contribute-geometry/' + temporal_data = {'start_date': '2010'} + response = self.client.post( + url, + data=json.dumps({'temporal_extent': temporal_data}), + content_type='application/json' + ) + self.assertEqual(response.status_code, 401) + + def test_publish_work_with_only_temporal_extent(self): + """Test that works with only temporal extent (no geometry) can be published.""" + # Set up publication with only temporal extent + pub = Publication.objects.create( + title='Publication with Only Temporal', + status='h', + doi='10.1234/only-temporal', + geometry=GeometryCollection(), # Empty + source=self.source, + timeperiod_startdate=['2010'], + timeperiod_enddate=['2020'] + ) + + self.client.login(username='admin@example.com', password='adminpass123') + url = f'/work/{pub.doi}/publish/' + response = self.client.post(url, content_type='application/json') + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + + pub.refresh_from_db() + self.assertEqual(pub.status, 'p') # Published + + def test_publish_work_with_only_geometry(self): + """Test that works with only geometry (no temporal extent) can be published.""" + # Set up publication with only geometry + pub = Publication.objects.create( + title='Publication with Only Geometry', + status='h', + doi='10.1234/only-geometry', + geometry=GeometryCollection(Point(13.405, 52.52)), + source=self.source, + timeperiod_startdate=None, + timeperiod_enddate=None + ) + + self.client.login(username='admin@example.com', password='adminpass123') + url = f'/work/{pub.doi}/publish/' + response = self.client.post(url, content_type='application/json') + + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertTrue(data['success']) + + pub.refresh_from_db() + self.assertEqual(pub.status, 'p') # Published + + def test_cannot_publish_without_any_extent(self): + """Test that harvested works without any extent cannot be published.""" + # Set up publication with neither geometry nor temporal extent + pub = Publication.objects.create( + title='Publication with No Extent', + status='h', + doi='10.1234/no-extent', + geometry=GeometryCollection(), # Empty + source=self.source, + timeperiod_startdate=None, + timeperiod_enddate=None + ) + + self.client.login(username='admin@example.com', password='adminpass123') + url = f'/work/{pub.doi}/publish/' + response = self.client.post(url, content_type='application/json') + + self.assertEqual(response.status_code, 400) + data = response.json() + self.assertIn('spatial or temporal extent', data['error']) + + pub.refresh_from_db() + self.assertEqual(pub.status, 'h') # Still harvested + + +class ContributePageFilterTests(TestCase): + """Test that contribute page shows publications missing either spatial or temporal extent.""" + + def setUp(self): + self.source = Source.objects.create( + name='Test Source', + is_oa=True, + is_preprint=False + ) + + def test_contribute_page_shows_missing_geometry(self): + """Contribute page should show publications missing geometry.""" + pub = Publication.objects.create( + title='Missing Geometry', + status='h', + doi='10.1234/missing-geo', + geometry=GeometryCollection(), # Empty + source=self.source, + timeperiod_startdate=['2010'], + timeperiod_enddate=['2020'] + ) + + response = self.client.get('/contribute/') + self.assertEqual(response.status_code, 200) + self.assertIn(pub, response.context['publications']) + + def test_contribute_page_shows_missing_temporal(self): + """Contribute page should show publications missing temporal extent.""" + pub = Publication.objects.create( + title='Missing Temporal', + status='h', + doi='10.1234/missing-temporal', + geometry=GeometryCollection(Point(13.405, 52.52)), + source=self.source, + timeperiod_startdate=None, + timeperiod_enddate=None + ) + + response = self.client.get('/contribute/') + self.assertEqual(response.status_code, 200) + self.assertIn(pub, response.context['publications']) + + def test_contribute_page_shows_missing_both(self): + """Contribute page should show publications missing both extents.""" + pub = Publication.objects.create( + title='Missing Both', + status='h', + doi='10.1234/missing-both', + geometry=GeometryCollection(), # Empty + source=self.source, + timeperiod_startdate=None, + timeperiod_enddate=None + ) + + response = self.client.get('/contribute/') + self.assertEqual(response.status_code, 200) + self.assertIn(pub, response.context['publications']) + + def test_contribute_page_hides_complete_publications(self): + """Contribute page should not show publications with both extents.""" + pub = Publication.objects.create( + title='Complete Publication', + status='h', + doi='10.1234/complete', + geometry=GeometryCollection(Point(13.405, 52.52)), + source=self.source, + timeperiod_startdate=['2010'], + timeperiod_enddate=['2020'] + ) + + response = self.client.get('/contribute/') + self.assertEqual(response.status_code, 200) + self.assertNotIn(pub, response.context['publications']) 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 <head> section (use re.DOTALL for multiline) import re self.assertIsNotNone(re.search(r'<head>.*<title>.*.*', 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)