Skip to content

Commit

Permalink
feat: eslasticsearch catalog
Browse files Browse the repository at this point in the history
feat: add back embargoed projects when querying public projects

feat: elasticsearch catalog

feat: add cron command to index catalog & cron job

feat: add geonames_id constraint to Location

fix: call to huma_num with no id during elasticsearch build

feat: divide object dating field in two - era  & period

fix mypy issues

fix(elasticsearch): from query param not passed correctly

feat: catalog data request

chore: move user related test factories to euphro_auth

fix: permission view on data request run inline

feat(es): add is_data_embargoed to RunDoc

feat(data-request): prevent request data from embargoed runs

feat: data request events

feat; add lru cache to eros fetch call

feat: fetch data from eros when building catalog
  • Loading branch information
wiwski committed Oct 23, 2024
1 parent 4d61cd9 commit 5dc2657
Show file tree
Hide file tree
Showing 105 changed files with 5,227 additions and 648 deletions.
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
CORS_ALLOWED_ORIGINS=http://localhost:8001,http://localhost:8002
DJANGO_SETTINGS_MODULE=euphrosyne.settings
DB_USER=
DB_NAME=euphrosyne
Expand All @@ -11,6 +12,9 @@ EMAIL_PORT=1025
EMAIL_HOST_USER=
EMAIL_HOST_PASSWORD=
EMAIL_USE_TLS=false
ELASTICSEARCH_HOST=http://localhost:9200
ELASTICSEARCH_USERNAME=
ELASTICSEARCH_PASSWORD=
EROS_HTTP_TOKEN=
EUPHROSYNE_TOOLS_API_URL=http://localhost:8001
DEFAULT_FROM_EMAIL=alexandre.hajjar@beta.gouv.fr
Expand Down
4 changes: 4 additions & 0 deletions cron.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@
"command": "0 */6 * * * python manage.py check_project_data_availability",
"size": "S"
},
{
"command": "5 */6 * * * python manage.py index_elasticsearch_catalog",
"size": "S"
},
{
"command": "0 0 * * * python manage.py run_checks",
"size": "S"
Expand Down
Empty file added data_request/__init__.py
Empty file.
182 changes: 182 additions & 0 deletions data_request/admin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
from django.contrib import admin, messages
from django.db.models import Model, QuerySet
from django.http import HttpRequest
from django.http.response import HttpResponse
from django.utils import timezone
from django.utils.safestring import mark_safe
from django.utils.translation import gettext
from django.utils.translation import gettext_lazy as _

from euphro_tools.exceptions import EuphroToolsException
from lab.admin.mixins import LabAdminAllowedMixin
from lab.permissions import is_lab_admin

from .data_links import send_links
from .models import DataAccessEvent, DataRequest


class ReadonlyInlineMixin:

def has_change_permission(
self,
request: HttpRequest,
obj: Model | None = None, # pylint: disable=unused-argument
) -> bool:
return False

def has_delete_permission(
self,
request: HttpRequest,
obj: Model | None = None, # pylint: disable=unused-argument
) -> bool:
return is_lab_admin(request.user)

def has_add_permission(
self,
request: HttpRequest,
obj: Model | None = None, # pylint: disable=unused-argument
) -> bool:
return False

def has_view_permission(
self,
request: HttpRequest,
obj: Model | None = None, # pylint: disable=unused-argument
) -> bool:
return is_lab_admin(request.user)


@admin.action(description=_("Accept request(s) (send download links)"))
def action_send_links(
# pylint: disable=unused-argument
modeladmin: "DataRequestAdmin",
request: HttpRequest,
queryset: QuerySet[DataRequest],
):
for data_request in queryset:
try:
send_links(data_request)
except EuphroToolsException as error:
modeladmin.message_user(
request,
_("Error sending links to %(email)s for %(data_request)s: %(error)s")
% {
"data_request": data_request,
"error": error,
"email": data_request.user_email,
},
level=messages.ERROR,
)
continue
data_request.sent_at = timezone.now()
if not data_request.request_viewed:
data_request.request_viewed = True
data_request.save()


class BeenSeenListFilter(admin.SimpleListFilter):
# Human-readable title which will be displayed in the
# right admin sidebar just above the filter options.
title = _("has been sent")

# Parameter for the filter that will be used in the URL query.
parameter_name = "been_sent"

def lookups(self, request, model_admin):
return [
("1", _("Yes")),
("0", _("No")),
]

def queryset(self, request: HttpRequest, queryset: QuerySet[DataRequest]):
if not self.value():
return queryset
return queryset.filter(sent_at__isnull=self.value() == "0")


class DataAccessEventInline(ReadonlyInlineMixin, admin.TabularInline):
model = DataAccessEvent
extra = 0

fields = ("path", "access_time")
readonly_fields = ("path", "access_time")


class RunInline(ReadonlyInlineMixin, admin.TabularInline):
model = DataRequest.runs.through
verbose_name = "Run"
verbose_name_plural = "Runs"
extra = 0

fields = ("run",)


@admin.register(DataRequest)
class DataRequestAdmin(LabAdminAllowedMixin, admin.ModelAdmin):
actions = [action_send_links]
list_filter = [BeenSeenListFilter]

list_display = (
"created",
"sent_at",
"user_email",
"user_first_name",
"user_last_name",
"display_viewed",
)

fields = (
"created",
"sent_at",
"user_email",
"user_first_name",
"user_last_name",
"user_institution",
"description",
)
readonly_fields = (
"created",
"user_email",
"user_first_name",
"user_last_name",
"user_institution",
"description",
)

inlines = [RunInline, DataAccessEventInline]

def has_change_permission(self, request: HttpRequest, obj: Model | None = None):
return False

def change_view(
self,
request: HttpRequest,
object_id: str,
form_url: str = "",
extra_context: dict[str, bool] | None = None,
) -> HttpResponse:
obj = self.get_object(request, object_id)
if obj and not obj.request_viewed:
obj.request_viewed = True
obj.save()
response = super().change_view(request, object_id, form_url, extra_context)
return response

def changelist_view(
self, request: HttpRequest, extra_context: dict[str, str] | None = None
):
extra_context = extra_context or {}
extra_context["title"] = gettext("Data requests")
return super().changelist_view(request, extra_context)

@admin.display(description=_("Is sent"), boolean=True)
def is_sent(self, obj: "DataRequest") -> bool:
return obj.sent_at is not None

@admin.display(description="")
def display_viewed(self, obj: "DataRequest") -> str:
if obj.request_viewed:
return ""
return mark_safe(
f'<p class="fr-badge fr-badge--new fr-badge--sm">{_("New")}</p>'
)
16 changes: 16 additions & 0 deletions data_request/api_urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
from django.urls import path

from . import api_views

urlpatterns = (
path(
"",
api_views.DataRequestCreateAPIView.as_view(),
name="create",
),
path(
"access-event",
api_views.DataAccessEventCreateAPIView.as_view(),
name="create-access-event",
),
)
52 changes: 52 additions & 0 deletions data_request/api_views.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
from rest_framework import generics, serializers

from data_request.emails import send_data_request_created_email
from euphro_auth.jwt.authentication import EuphrosyneAdminJWTAuthentication
from lab.runs.models import Run

from .models import DataAccessEvent, DataRequest


class DataRequestSerializer(serializers.ModelSerializer):
runs = serializers.PrimaryKeyRelatedField(
many=True,
queryset=Run.objects.only_not_embargoed(),
allow_empty=False,
)

class Meta:
model = DataRequest
fields = [
"user_email",
"user_first_name",
"user_last_name",
"user_institution",
"description",
"runs",
]


class DataRequestCreateAPIView(generics.CreateAPIView):
queryset = DataRequest.objects.all()
serializer_class = DataRequestSerializer

def perform_create(self, serializer: DataRequestSerializer):
super().perform_create(serializer)
send_data_request_created_email(serializer.instance.user_email)


class DataAccessEventSerializer(serializers.ModelSerializer):
data_request = serializers.PrimaryKeyRelatedField(
queryset=DataRequest.objects.all(),
allow_empty=False,
)

class Meta:
model = DataAccessEvent
fields = ["data_request", "path"]


class DataAccessEventCreateAPIView(generics.CreateAPIView):
queryset = DataAccessEvent.objects.all()
serializer_class = DataAccessEventSerializer
authentication_classes = [EuphrosyneAdminJWTAuthentication]
6 changes: 6 additions & 0 deletions data_request/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class DataRequestConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "data_request"
44 changes: 44 additions & 0 deletions data_request/data_links.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import datetime
import typing

from euphro_tools.download_urls import (
DataType,
fetch_token_for_run_data,
generate_download_url,
)

from .emails import LinkDict, send_data_email
from .models import DataRequest

NUM_DAYS_VALID = 7


def send_links(data_request: DataRequest):
links: list[LinkDict] = []
expiration = datetime.datetime.now() + datetime.timedelta(days=NUM_DAYS_VALID)
for run in data_request.runs.all():
for data_type in typing.get_args(DataType):
project_name = run.project.name
token = fetch_token_for_run_data(
run.project.slug,
run.label,
data_type,
expiration=expiration,
data_request_id=str(data_request.id),
)
links.append(
{
"name": f"{run.label} ({project_name})",
"url": generate_download_url(
data_type=data_type,
project_slug=run.project.slug,
run_label=run.label,
token=token,
),
"data_type": data_type,
}
)
send_data_email(
context={"links": links, "expiration_date": expiration},
email=data_request.user_email,
)
65 changes: 65 additions & 0 deletions data_request/emails.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import datetime
import logging
import smtplib
import typing

from django.core import mail
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.utils.translation import gettext as _

logger = logging.getLogger(__name__)


class LinkDict(typing.TypedDict):
name: str
url: str
data_type: typing.Literal["raw_data", "processed_data"]


class DataEmailContext(typing.TypedDict):
links: list[LinkDict]
expiration_date: datetime.datetime


def send_data_request_created_email(
email: str,
):
subject = _("[New AGLAE] Data request received")
template_path = "data_request/email/data-request-created.html"
_send_mail(subject, email, template_path)


def send_data_email(
email: str,
context: DataEmailContext,
):
subject = _("Your New AGLAE data links")
template_path = "data_request/email/data-links.html"
_send_mail(subject, email, template_path, context)


def _send_mail(
subject: str,
email: str,
template_path: str,
context: typing.Mapping[str, typing.Any] | None = None,
):
html_message = render_to_string(template_path, context=context)
plain_message = strip_tags(html_message)

try:
mail.send_mail(
subject,
plain_message,
from_email=None,
recipient_list=[email],
html_message=html_message,
)
except (smtplib.SMTPException, ConnectionError) as e:
logger.error(
"Error sending data request email to %s. Reason: %s",
email,
str(e),
)
raise e
Loading

0 comments on commit 5dc2657

Please sign in to comment.