diff --git a/backend/settings.py b/backend/settings.py index d0d2281..4daff6f 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -81,6 +81,7 @@ "zotero_publications_app", "hydrolearn_modules_app", "hydroshare_resources_app", + "hydroshare_community_resources_app", ] MIDDLEWARE = [ diff --git a/backend/urls.py b/backend/urls.py index 93294be..255db7f 100644 --- a/backend/urls.py +++ b/backend/urls.py @@ -9,6 +9,10 @@ path("zotero_publications_app/", include("zotero_publications_app.urls")), path("hydrolearn_modules_app/", include("hydrolearn_modules_app.urls")), path("hydroshare_resources_app/", include("hydroshare_resources_app.urls")), + path( + "hydroshare_community_resources_app/", + include("hydroshare_community_resources_app.urls"), + ), ] # if settings.DEBUG: diff --git a/hydroshare_community_resources_app/__init__.py b/hydroshare_community_resources_app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hydroshare_community_resources_app/admin.py b/hydroshare_community_resources_app/admin.py new file mode 100644 index 0000000..e69de29 diff --git a/hydroshare_community_resources_app/apps.py b/hydroshare_community_resources_app/apps.py new file mode 100644 index 0000000..bff39bf --- /dev/null +++ b/hydroshare_community_resources_app/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class HydroshareCommunitiesResourcesAppConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "hydroshare_community_resources_app" diff --git a/hydroshare_community_resources_app/cms_plugins.py b/hydroshare_community_resources_app/cms_plugins.py new file mode 100644 index 0000000..719467d --- /dev/null +++ b/hydroshare_community_resources_app/cms_plugins.py @@ -0,0 +1,20 @@ +from cms.plugin_base import CMSPluginBase +from cms.plugin_pool import plugin_pool +from django.utils.translation import gettext_lazy as _ +from .models import HydroShareCommunityResourcesList + +import logging + +logger = logging.getLogger(__name__) + + +@plugin_pool.register_plugin +class HydroShareCommunityResourcesList(CMSPluginBase): + model = HydroShareCommunityResourcesList + name = _("HydroShare Community Resources Plugin") + render_template = "hydroshare-community-resources.html" + cache = False + + def render(self, context, instance, placeholder): + context = super().render(context, instance, placeholder) + return context diff --git a/hydroshare_community_resources_app/migrations/0001_initial.py b/hydroshare_community_resources_app/migrations/0001_initial.py new file mode 100644 index 0000000..41e0a30 --- /dev/null +++ b/hydroshare_community_resources_app/migrations/0001_initial.py @@ -0,0 +1,32 @@ +# Generated by Django 3.2 on 2024-10-01 18:31 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('cms', '0022_auto_20180620_1551'), + ] + + operations = [ + migrations.CreateModel( + name='HydroShareCommunityResourcesList', + fields=[ + ('cmsplugin_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='hydroshare_community_resources_app_hydrosharecommunityresourceslist', serialize=False, to='cms.cmsplugin')), + ('user', models.CharField(blank=True, default='', max_length=200)), + ('password', models.CharField(blank=True, default='', max_length=200)), + ('community_id', models.CharField(default='', max_length=200)), + ('placeholder_image', models.CharField(default='https://www.tethysplatform.org/images/tethys_data.png', max_length=200)), + ('updated_version', models.IntegerField(default=0, editable=False)), + ('resources', models.JSONField(default=dict, editable=False)), + ], + options={ + 'abstract': False, + }, + bases=('cms.cmsplugin',), + ), + ] diff --git a/hydroshare_community_resources_app/migrations/__init__.py b/hydroshare_community_resources_app/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/hydroshare_community_resources_app/models.py b/hydroshare_community_resources_app/models.py new file mode 100644 index 0000000..b088ff9 --- /dev/null +++ b/hydroshare_community_resources_app/models.py @@ -0,0 +1,16 @@ +from cms.models.pluginmodel import CMSPlugin +from django.db import models +import logging + +logger = logging.getLogger(__name__) + + +class HydroShareCommunityResourcesList(CMSPlugin): + user = models.CharField(max_length=200, default="", blank=True) + password = models.CharField(max_length=200, default="", blank=True) + community_id = models.CharField(max_length=200, default="") + placeholder_image = models.CharField( + max_length=200, default="https://www.tethysplatform.org/images/tethys_data.png" + ) + updated_version = models.IntegerField(default=0, editable=False) + resources = models.JSONField(editable=False, default=dict) diff --git a/hydroshare_community_resources_app/static/css/hs_community_resources.css b/hydroshare_community_resources_app/static/css/hs_community_resources.css new file mode 100644 index 0000000..a89193a --- /dev/null +++ b/hydroshare_community_resources_app/static/css/hs_community_resources.css @@ -0,0 +1,179 @@ +:root { + --image-width: 200px; + --image-height: 200px; + } + + .img-tile{ + object-fit: cover; + } + + .name{ + height: 150px; + cursor: pointer; + } + + /* Hide scrollbar for Chrome, Safari and Opera */ + .name::-webkit-scrollbar { + display: none; + } + + /* Hide scrollbar for IE, Edge and Firefox */ + .name { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ + } + + .hydroshare_resource{ + box-shadow: 0 1px 10px 0 rgba(0,0,0,0.125), inset 0 0 0 1px rgba(255,255,255,0.75); + border-color: white; + border-bottom: 3px solid #255f9c; + } + .hydroshare_resource:hover, + .hydroshare_resource:focus { + background: #f5f5f5; + border-color: #bebebe; + box-shadow: 0 1px 4px 0 rgba(0,117,180,0.4); + } + .cover-image { + position: relative; + display: flex; + justify-content: center; + align-items: center; + overflow: hidden; + cursor: pointer; + } + + .cover-image img { + display: block; + width: 100%; + transition: filter 0.5s ease; /* Smooth transition for the filter */ + } + + .learn-more { + position: absolute; + color: white; + padding: 5px 10px; + visibility: hidden; + transition: visibility 0s, opacity 0.5s linear; /* Smooth transition for visibility and opacity */ + opacity: 0; + border-color: #255f9c; + border-radius: 3px; + background: #255f9c; + } + + .cover-image:hover .learn-more { + visibility: visible; + opacity: 1; /* Make it fully opaque on hover */ + } + + .cover-image:hover img { + filter: brightness(50%); /* Darken the image */ + } + .learn-more > a { + color: inherit; + text-decoration: none; + } + + .hidden { + display: none; + } + @keyframes placeHolderShimmer{ + 0%{ + background-position: -800px 0 + } + 100%{ + background-position: 800px 0 + } + } + + + + .animated-background { + animation-duration: 7s; + animation-fill-mode: forwards; + animation-iteration-count: infinite; + animation-name: placeHolderShimmer; + animation-timing-function: linear; + } + .titles-loading-background{ + background: #cbcbcb; + background: linear-gradient(to right, #cbcbcb 8%, #bbbbbb 18%, #cbcbcb 33%); + } + .descriptions-background{ + background: linear-gradient(to right, #eeeeee 8%, #dddddd 18%, #eeeeee 33%); + } + + /* The following are css for the toggle */ + @import url("https://fonts.googleapis.com/css?family=Inter:400'"); + + /* Include FontAwesome for icons */ + @import url("https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"); + + .middle { + width: 100%; + padding-bottom: 50px; + text-align: center; + } + + .middle h1 { + font-family: "Inter", sans-serif; + color: #fff; + } + + .middle input[type="radio"] { + display: none; + } + + .middle input[type="radio"]:checked + .box { + background-color: #19a7ce; + } + + .middle input[type="radio"]:checked + .box span { + color: white; + transform: translateY(35px); /* Adjusted from 70px */ + } + + .middle input[type="radio"]:checked + .box span:before { + transform: translateY(0px); + opacity: 1; + } + + .middle .box { + width: 100px; /* Adjusted from 200px */ + height: 100px; /* Adjusted from 200px */ + background-color: #fff; + transition: all 250ms ease; + will-change: transform; + display: inline-block; + text-align: center; + cursor: pointer; + position: relative; + font-family: "Inter", sans-serif; + font-weight: 900; + } + + .middle .box:active { + transform: translateY(5px); /* Adjusted from 10px */ + } + + .middle .box span { + position: absolute; + transform: translate(0, 30px); /* Adjusted from 60px */ + left: 0; + right: 0; + transition: all 300ms ease; + font-size: 1em; /* Adjusted from 1.5em */ + user-select: none; + color: #19a7ce; + } + + .middle .box span:before { + font-size: 0.8em; /* Adjusted from 1.2em */ + font-family: FontAwesome; + display: block; + transform: translateY(-40px); /* Adjusted from -80px */ + opacity: 0; + transition: all 300ms ease-in-out; + font-weight: normal; + color: white; + } \ No newline at end of file diff --git a/hydroshare_community_resources_app/static/images/files.png b/hydroshare_community_resources_app/static/images/files.png new file mode 100644 index 0000000..bcd5348 Binary files /dev/null and b/hydroshare_community_resources_app/static/images/files.png differ diff --git a/hydroshare_community_resources_app/static/js/hs_community_resources.js b/hydroshare_community_resources_app/static/js/hs_community_resources.js new file mode 100644 index 0000000..cc92c45 --- /dev/null +++ b/hydroshare_community_resources_app/static/js/hs_community_resources.js @@ -0,0 +1,160 @@ + + +const getCSRFToken = () => { + // Attempt to retrieve the CSRF token from the meta tag + const token = document.querySelector('meta[name="csrf-token"]').getAttribute('content'); + return token; +} + +const fetchHydroShareResources = () => { + // Update the URL if your setup is different or if you are using a production server + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': getCSRFToken(), // Include the CSRF token in the request headers + }, + body: JSON.stringify(requestData), + }) + .then(response => { + document.getElementById('placeholder-hydroshare-resources').classList.add('hidden'); + document.getElementById('hydroshare-resources-list-plugin').classList.remove('hidden'); + + if (!response.ok) { + throw new Error('Network response was not ok'); + } + return response.json(); // Assuming the response is JSON + }) + .then(data => { + let resourcesHTML = `` + data['resources'].forEach(resource => { + resourcesHTML += ` +
+
+
` + + resourcesHTML += ` +
+
+ + ... + +
` + + + + resourcesHTML += ` +
+
+
+ +
${resource.title}
` + + resourcesHTML += `
` + resourcesHTML +=`

` + // resourcesHTML +=` HydroShare` + resourcesHTML +=`

` + + + resourcesHTML +=`

View on HydroShare

` + + resourcesHTML +=`
+

+ ${resource.abstract} +

+
` + resourcesHTML += `
` + + }); + + document.getElementById('hydroshare-resources-list-plugin').innerHTML = resourcesHTML; + + // For example, you could iterate over the data and append it to an element in your HTML + }) + .catch(error => { + console.error('There was a problem with the fetch operation:', error); + }); + } + +const create_placeholders = () => { + console.log('Creating placeholders'); + let placeholdersHtmlElement = document.getElementById('placeholder-hydroshare-resources'); + let htmlPlaceholders = ''; + for(var i = 0; i < 10; i++){ + htmlPlaceholders+=` +
+
+
+ +
+
+ + +
+
+ +
+
+
+
+
+
+ +

+ +
+

+

+
+
+
+
+
+ + ` + } + placeholdersHtmlElement.innerHTML = htmlPlaceholders; +} + + +document.addEventListener('DOMContentLoaded', function() { + // Select all radio buttons with name 'radio' + const radioButtons = document.querySelectorAll('input[name="radio"]'); + + // Function to handle selection changes + function handleSelection(value) { + document.getElementById('placeholder-hydroshare-resources').classList.remove('hidden'); + document.getElementById('hydroshare-resources-list-plugin').classList.add('hidden'); + console.log(`${value.charAt(0).toUpperCase() + value.slice(1)} selected`); + requestData.curated = (value === 'curated'); + create_placeholders(); + fetchHydroShareResources(); + } + + // Initial function call based on default selection + const selectedRadio = document.querySelector('input[name="radio"]:checked'); + if (selectedRadio && selectedRadio.value) { + handleSelection(selectedRadio.value); + } else { + console.error('No radio button is selected by default.'); + } + + // Add event listeners to each radio button + radioButtons.forEach(function(radio) { + radio.addEventListener('change', function() { + if (this.checked) { + handleSelection(this.value); + } + }); + }); +}); + +// Inspiration for radio buttons +// https://codepen.io/gabrielferreira/pen/oYxNVy \ No newline at end of file diff --git a/hydroshare_community_resources_app/templates/hydroshare-community-resources-base.html b/hydroshare_community_resources_app/templates/hydroshare-community-resources-base.html new file mode 100644 index 0000000..bae0781 --- /dev/null +++ b/hydroshare_community_resources_app/templates/hydroshare-community-resources-base.html @@ -0,0 +1,16 @@ +{% extends CMS_TEMPLATE %} + +{% load sekizai_tags static cms_tags %} + +{% addtoblock "js" %} + +{% endaddtoblock %} + +{% addtoblock "css" %} + + +{% endaddtoblock %} + +{% block content %} + +{% endblock %} diff --git a/hydroshare_community_resources_app/templates/hydroshare-community-resources.html b/hydroshare_community_resources_app/templates/hydroshare-community-resources.html new file mode 100644 index 0000000..f65e660 --- /dev/null +++ b/hydroshare_community_resources_app/templates/hydroshare-community-resources.html @@ -0,0 +1,49 @@ +{% load cms_tags sekizai_tags static %} + + {% csrf_token %} + + + +{% addtoblock "css" %} + + +{% endaddtoblock %} + +{% addtoblock "js" %} + + + +{% endaddtoblock %} + +{% block content %} +
+

CSS Radio Button

+ + +
+ +
+ + +{% endblock %} + + \ No newline at end of file diff --git a/hydroshare_community_resources_app/urls.py b/hydroshare_community_resources_app/urls.py new file mode 100644 index 0000000..7879873 --- /dev/null +++ b/hydroshare_community_resources_app/urls.py @@ -0,0 +1,13 @@ +from django.urls import re_path + + +from .views import base_view, hydroshare_community_resources_view + +urlpatterns = [ + re_path(r"^$", base_view, name="base"), + re_path( + r"^update-hydroshare-community-resources/$", + hydroshare_community_resources_view, + name="update-hydroshare-community-resources", + ), +] diff --git a/hydroshare_community_resources_app/utils.py b/hydroshare_community_resources_app/utils.py new file mode 100644 index 0000000..d7649f0 --- /dev/null +++ b/hydroshare_community_resources_app/utils.py @@ -0,0 +1,124 @@ +import requests +from bs4 import BeautifulSoup +import json +from itertools import chain +import datetime +import re + + +def get_curated_resources( + composite_resource_id="302dcbef13614ac486fb260eaa1ca87c", hs=None +): + science = hs.getScienceMetadata(composite_resource_id) + relations = science.get("relations", []) + resource_ids = [] + for item in relations: + if item.get("type") == "hasPart": + value = item.get("value", "") + # Regular expression to find the resource ID in the URL + match = re.search( + r"http://www\.hydroshare\.org/resource/([a-f0-9]{32})", value + ) + if match: + resource_id = match.group(1) + resource_ids.append(resource_id) + return resource_ids + + +def filter_resources_list_by_resources_id(resources, ids): + filtered_resources = [] + for resource in resources: + if resource["resource_id"] in ids: + filtered_resources.append(resource) + return filtered_resources + + +def join_generators(generators): + seen_resource_ids = set() + for gen in generators: + for item in gen: + resource_id = item.get("resource_id") + if resource_id not in seen_resource_ids: + seen_resource_ids.add(resource_id) + yield item + + +def get_group_ids(community_id): + url = f"https://www.hydroshare.org/community/{community_id}/" + response = requests.get(url) + soup = BeautifulSoup(response.content, "html.parser") + + group_ids = [] + + # Find the script tag with id 'community-app-data' + script_tag = soup.find("script", id="community-app-data", type="application/json") + if script_tag and script_tag.string: + data = json.loads(script_tag.string) + # The group data is in data['members'] + for group in data.get("members", []): + group_id = group.get("id") + if group_id: + href = f"{group_id}" + group_ids.append(href) + else: + print( + "No script tag with id 'community-app-data' found or it contains no data." + ) + + return group_ids + + +def get_dict_with_attribute(list_of_dicts, attribute, value): + # Loop through each dictionary in the list + for dictionary in list_of_dicts: + # logging.warning(dictionary) + # if attribute in dictionary: + # logging.warning(dictionary[attribute]) + # Check if the attribute exists in the dictionary and has the specified value + if attribute in dictionary and dictionary[attribute] == value: + return dictionary # Return the dictionary if found + + return None # Return None if not found in any dictionary + + +def get_most_recent_date(date_local_resource, date_api): + # logging.warning(f'{date_local_resource} , {date_api}') + + # Convert strings to datetime objects + date_time_local_resource = datetime.datetime.fromisoformat(date_local_resource[:-1]) + date_time_api = datetime.datetime.fromisoformat(date_api[:-1]) + # logging.warning(f'{date_time_local_resource} , {date_time_api}') + # Compare the datetime objects + if date_time_local_resource > date_time_api: + return False + elif date_time_local_resource < date_time_api: + return True + else: + return False + + +def update_resource(resource): + single_resource = {} + single_resource = { + "title": resource["resource_title"], + "abstract": resource["abstract"], + "resource_id": resource["resource_id"], + "date_last_updated": resource["date_last_updated"], + "resource_type": resource["resource_type"], + "resource_url": resource["resource_url"], + } + return single_resource + + +def extract_value_by_name(html, name): + soup = BeautifulSoup(html, "html.parser") + rows = soup.select("#extraMetaTable tbody tr") + + for row in rows: + name_cell = row.select_one("td:first-child") + value_cell = row.select_one("td:nth-child(2)") + + if name_cell and value_cell and name_cell.get_text(strip=True) == name: + return value_cell.get_text(strip=True) + + return None diff --git a/hydroshare_community_resources_app/views.py b/hydroshare_community_resources_app/views.py new file mode 100644 index 0000000..a94c1e0 --- /dev/null +++ b/hydroshare_community_resources_app/views.py @@ -0,0 +1,100 @@ +from django.utils.translation import gettext_lazy as _ +from .utils import ( + join_generators, + get_group_ids, + get_dict_with_attribute, + get_most_recent_date, + update_resource, + filter_resources_list_by_resources_id, + get_curated_resources, +) + +from .models import ( + HydroShareCommunityResourcesList, +) +from hs_restclient import HydroShare, HydroShareAuthBasic +import logging +import json +from django.http import JsonResponse +from django.shortcuts import render + +logger = logging.getLogger(__name__) + + +def base_view(request): + + context = {} + return render(request, "hydroshare-community-resources-base.html", context) + + +def hydroshare_community_resources_view(request): + + body_unicode = request.body.decode("utf-8") + body = json.loads(body_unicode) + instance = HydroShareCommunityResourcesList.objects.get(id=body["instance_id"]) + is_curated = body["curated"] + + json_resources = {"resources": []} + if instance.user != "" and instance.password != "": + auth = HydroShareAuthBasic(username=instance.user, password=instance.password) + hs = HydroShare(auth=auth) + else: + hs = HydroShare(prompt_auth=False) + try: + generators_rs = [] + + # GET THE IDS OF THE GROUPS FROM THE COMMUNITY + group_ids = get_group_ids(instance.community_id) + + # QUERY THE RESOURCES OF EACH ONE OF THE GROUPS + for group_id in group_ids: + generators_rs.append(hs.resources(group=group_id)) + + # JOIN THE RESOURCES OF EACH ONE OF THE GROUPS AND ALSO MAKE SURE NO RESOURCE IS REPEATED + resources_api = join_generators(generators_rs) + + # FILTER THE RESOURCES BY THE CURATED IDS IF NEEDED + if is_curated: + curated_ids = get_curated_resources(hs=hs) + resources_api = filter_resources_list_by_resources_id( + resources_api, curated_ids + ) + resources_model = instance.resources.get("resources", []) + + for resource_api in resources_api: + matching_resource_model = get_dict_with_attribute( + resources_model, "resource_id", resource_api["resource_id"] + ) + + # If resource found locally, then check last update date + if matching_resource_model: + is_recent_date = get_most_recent_date( + matching_resource_model["date_last_updated"], + resource_api["date_last_updated"], + ) + if ( + is_recent_date + ): # If the resource retrieved from api is more recent, then update resource + # logging.warning("resource has a more recent version") + single_resource = update_resource(resource_api) + # logging.warning(single_resource) + json_resources["resources"].append(single_resource) + instance.resources = json_resources + + else: # resource is the same, then retrieve the resource saved locally + single_resource = matching_resource_model + json_resources["resources"].append(single_resource) + instance.resources = json_resources + # If the resource is not here then create one + else: + single_resource = update_resource(resource_api) + json_resources["resources"].append(single_resource) + + instance.resources = json_resources + + instance.save(update_fields=["resources"]) + return JsonResponse(json_resources) + + except Exception as e: + logging.warning(e) + instance.resources = {"Error": [f"The following error: {e}"]} diff --git a/setup.sh b/setup.sh index 5d95fef..b49bc9d 100755 --- a/setup.sh +++ b/setup.sh @@ -66,6 +66,11 @@ python manage.py migrate hydrolearn_modules_app python manage.py makemigrations hydroshare_resources_app python manage.py migrate hydroshare_resources_app +python manage.py makemigrations hydroshare_community_resources_app +python manage.py migrate hydroshare_community_resources_app + + + if [ -z "${SKIP_CREATION_USER}" ]; then python manage.py createsuperuser --noinput --username $DJANGO_SUPERUSER_USERNAME --email $DJANGO_SUPERUSER_EMAIL fi