Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

#470 Notification Task and Logic #475

Merged
merged 41 commits into from
Nov 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
d5f9cb4
fix importing local settings for docker settings and refine compose file
HesamKorki Oct 18, 2023
c9fff05
refine celery config and create the task definition
HesamKorki Oct 19, 2023
10a6e45
add NotifyMixin
HesamKorki Oct 20, 2023
43fc50e
apply black linter
HesamKorki Oct 23, 2023
36712e3
start the dataset notification logic
HesamKorki Oct 23, 2023
08d63dd
implement the notification mixin for dataset logic
HesamKorki Oct 25, 2023
43cb767
fix importing local settings for docker settings and refine compose file
HesamKorki Oct 18, 2023
0acdd12
refine celery config and create the task definition
HesamKorki Oct 19, 2023
0e57480
add NotifyMixin
HesamKorki Oct 20, 2023
2ba2e30
apply black linter
HesamKorki Oct 23, 2023
132d813
start the dataset notification logic
HesamKorki Oct 23, 2023
ed6f369
implement the notification mixin for dataset logic
HesamKorki Oct 25, 2023
4b5e0e6
refine the logic on dataset notif
HesamKorki Oct 25, 2023
42e2a0f
solve conflicts
HesamKorki Oct 25, 2023
e47c3a4
add finishing touches for the dataset notification logic
HesamKorki Oct 31, 2023
9e72e1c
fix the typing for user without importing directly
HesamKorki Oct 31, 2023
6085112
add business logic to notify about the accesses
HesamKorki Nov 2, 2023
ab13bdc
add notification logic of the project entity
HesamKorki Nov 2, 2023
db1907b
add document notification logic
HesamKorki Nov 6, 2023
061b192
refactor and fix for end-to-end test
HesamKorki Nov 6, 2023
53da6ea
using abc module to enforce the implementation
HesamKorki Nov 6, 2023
017f206
remove unnecessary imports
HesamKorki Nov 7, 2023
0c0fb94
add test for dataset notification logic
HesamKorki Nov 7, 2023
21041dc
fix backward relationship between project and local custodians
HesamKorki Nov 8, 2023
3213571
add tests for access notification logic
HesamKorki Nov 8, 2023
cb3e1d6
add more tests for edge cases and anti paths
HesamKorki Nov 8, 2023
3797fe1
add tests for document notifications
HesamKorki Nov 8, 2023
12dd5d5
modify docstring
HesamKorki Nov 8, 2023
d6820db
fix typo in task name
HesamKorki Nov 9, 2023
2968ecf
separate abstract and static decorators
HesamKorki Nov 9, 2023
15b050c
fixing typos
HesamKorki Nov 9, 2023
ec0701f
revert change to the celery app config file
HesamKorki Nov 9, 2023
14fbdca
remove ABC
HesamKorki Nov 9, 2023
84e90ba
solve the metaclass problem
HesamKorki Nov 9, 2023
8a6621a
refactor and abstract the user loop
HesamKorki Nov 10, 2023
3077444
add more logging around notifications
HesamKorki Nov 10, 2023
2e204b1
remove excessive list call
HesamKorki Nov 13, 2023
e65d977
update the setting template to add schedule of the new notif tast
HesamKorki Nov 13, 2023
d663092
Merge branch 'develop' into 470-notif-user-loop
HesamKorki Nov 13, 2023
2bde86d
Merge branch 'develop' into 470-notif-user-loop
HesamKorki Nov 13, 2023
6233822
run black locally
HesamKorki Nov 13, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions core/migrations/0035_auto_20231108_1041.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Generated by Django 3.2.20 on 2023-11-08 09:41

from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion


class Migration(migrations.Migration):
dependencies = [
("core", "0034_auto_20230515_1353"),
]

operations = [
migrations.AlterField(
model_name="access",
name="user",
field=models.ForeignKey(
blank=True,
help_text="Use either `contact` or `user`",
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="accesses",
to=settings.AUTH_USER_MODEL,
verbose_name="User that has the access",
),
),
migrations.AlterField(
model_name="project",
name="local_custodians",
field=models.ManyToManyField(
help_text="Custodians are the local responsibles for the project. This list must include a PI.",
related_name="project_set",
to=settings.AUTH_USER_MODEL,
verbose_name="Local custodians",
),
),
]
84 changes: 79 additions & 5 deletions core/models/access.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,31 @@
import logging
import typing

from datetime import datetime, date
from datetime import datetime, date, timedelta
from typing import List

from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q, ObjectDoesNotExist, Count, signals

from enumchoicefield import EnumChoiceField, ChoiceEnum

from .utils import CoreModel
from .utils import CoreModel, CoreNotifyMeta
from notification import NotifyMixin
from notification.models import NotificationVerb, Notification
from core.utils import DaisyLogger

from auditlog.registry import auditlog
from auditlog.models import AuditlogHistoryField

if typing.TYPE_CHECKING:
User = settings.AUTH_USER_MODEL

logger = logging.getLogger(__name__)

logger = DaisyLogger(__name__)


class StatusChoices(ChoiceEnum):
Expand All @@ -25,7 +35,7 @@ class StatusChoices(ChoiceEnum):
terminated = "Terminated"


class Access(CoreModel):
class Access(CoreModel, NotifyMixin, metaclass=CoreNotifyMeta):
"""
Represents the access given to an internal (LCSB) entity over data storage locations.
"""
Expand Down Expand Up @@ -161,7 +171,7 @@ def clean(self):

user = models.ForeignKey(
"core.User",
related_name="user",
related_name="accesses",
verbose_name="User that has the access",
on_delete=models.CASCADE,
null=True,
Expand Down Expand Up @@ -240,5 +250,69 @@ def is_active(self):

return True

@staticmethod
def get_notification_recipients():
"""
Get distinct users that are local custodian of a dataset or a project.
"""

return (
get_user_model()
.objects.filter(Q(datasets__isnull=False) | Q(project_set__isnull=False))
.distinct()
)

@classmethod
def make_notifications_for_user(
cls, day_offset: timedelta, exec_date: date, user: "User"
):
# Considering users that are indirectly responsible for the dataset (through projects)
possible_datasets = set(user.datasets.all())
possible_datasets.update(
[
dataset
for project in user.project_set.all()
for dataset in project.datasets.all()
]
)
# Fetch all necessary data at once before the loop
dataset_ids = [dataset.id for dataset in possible_datasets]
accesses = Access.objects.filter(
Q(dataset_id__in=dataset_ids) & Q(status=StatusChoices.active)
)

for access in accesses:
# Check if the dataset has an access that is about to expire
if (
access.grant_expires_on
and access.grant_expires_on - day_offset == exec_date
):
cls.notify(user, access, NotificationVerb.expire)

@staticmethod
def notify(user: "User", obj: "Access", verb: "NotificationVerb"):
"""
Notifies concerning users about the entity.
"""
offset = user.notification_setting.notification_offset
dispatch_by_email = user.notification_setting.send_email
dispatch_in_app = user.notification_setting.send_in_app

msg = f"Access for {obj.dataset.title} of the user {obj.user or obj.contact} is ending in {offset} days."
on = obj.grant_expires_on

logger.info(f"Creating a notification for {user} : {msg}")

Notification.objects.create(
recipient=user,
verb=verb,
message=msg,
on=on,
dispatch_by_email=dispatch_by_email,
dispatch_in_app=dispatch_in_app,
content_type=ContentType.objects.get_for_model(obj),
object_id=obj.id,
).save()


auditlog.register(Access)
89 changes: 85 additions & 4 deletions core/models/dataset.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,34 @@
import uuid
import datetime
from datetime import timedelta
import typing

from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import Q
from django.urls import reverse
from django.utils.module_loading import import_string

from guardian.models import GroupObjectPermissionBase, UserObjectPermissionBase

from core import constants
from core.utils import DaisyLogger
from core.models import DataDeclaration
from core.permissions.mapping import PERMISSION_MAPPING

from .utils import CoreTrackedModel, TextFieldWithInputWidget
from notification import NotifyMixin
from notification.models import Notification, NotificationVerb
from .utils import CoreTrackedModel, TextFieldWithInputWidget, CoreNotifyMeta
from .partner import HomeOrganisation

if typing.TYPE_CHECKING:
User = settings.AUTH_USER_MODEL


class Dataset(CoreTrackedModel):
logger = DaisyLogger(__name__)


class Dataset(CoreTrackedModel, NotifyMixin, metaclass=CoreNotifyMeta):
class Meta:
app_label = "core"
get_latest_by = "added"
Expand Down Expand Up @@ -227,6 +242,72 @@ def publish(self, save=True):
for data_declaration in self.data_declarations.all():
data_declaration.publish_subentities()

@staticmethod
def get_notification_recipients():
"""
Get distinct users that are local custodian of a dataset or a project.
"""

return (
get_user_model()
.objects.filter(Q(datasets__isnull=False) | Q(project_set__isnull=False))
.distinct()
)

@classmethod
def make_notifications_for_user(
cls, day_offset: timedelta, exec_date: datetime.date, user: "User"
):
# Considering users that are indirectly responsible for the dataset (through projects)
possible_datasets = set(user.datasets.all())
for project in user.project_set.all():
possible_datasets.update(project.datasets.all())
for dataset in possible_datasets:
# Data Declaration (Embargo Date & End of Storage Duration)
for data_declaration in dataset.data_declarations.all():
if (
data_declaration.embargo_date
and data_declaration.embargo_date - day_offset == exec_date
):
cls.notify(user, data_declaration, NotificationVerb.embargo_end)
if (
data_declaration.end_of_storage_duration
and data_declaration.end_of_storage_duration - day_offset
== exec_date
):
cls.notify(user, data_declaration, NotificationVerb.end)

@staticmethod
def notify(user: "User", obj: "DataDeclaration", verb: "NotificationVerb"):
"""
Notifies concerning users about the entity.
"""
offset = user.notification_setting.notification_offset
dispatch_by_email = user.notification_setting.send_email
dispatch_in_app = user.notification_setting.send_in_app

if verb == NotificationVerb.embargo_end:
msg = f"Embargo for {obj.dataset.title} is ending in {offset} days."
on = obj.embargo_date
else:
msg = (
f"Storage duration for {obj.dataset.title} is ending in {offset} days."
)
on = obj.end_of_storage_duration

logger.info(f"Creating a notification for {user} : {msg}")

Notification.objects.create(
recipient=user,
verb=verb,
message=msg,
on=on,
dispatch_by_email=dispatch_by_email,
dispatch_in_app=dispatch_in_app,
content_type=ContentType.objects.get_for_model(obj.dataset),
object_id=obj.dataset.id,
).save()


# faster lookup for permissions
# https://django-guardian.readthedocs.io/en/stable/userguide/performance.html#direct-foreign-keys
Expand Down
76 changes: 72 additions & 4 deletions core/models/document.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,30 @@
import os
import typing
import datetime
from datetime import timedelta
from model_utils import Choices

from django.db import models
from django.conf import settings
from django.db.models import Q
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django.db.models.signals import post_delete
from django.dispatch import receiver
from django.core.files.storage import default_storage
from model_utils import Choices
from .utils import CoreModel
from core import constants

from .utils import CoreModel, CoreNotifyMeta
from core.utils import DaisyLogger
from notification.models import Notification, NotificationVerb
from notification import NotifyMixin

if typing.TYPE_CHECKING:
User = settings.AUTH_USER_MODEL


logger = DaisyLogger(__name__)


def get_file_name(instance, filename):
Expand All @@ -21,7 +37,7 @@ def get_file_name(instance, filename):
)


class Document(CoreModel):
class Document(CoreModel, NotifyMixin, metaclass=CoreNotifyMeta):
"""
Represents a document
"""
Expand Down Expand Up @@ -77,6 +93,58 @@ def shortname(self):
def size(self):
return self.content.size

@staticmethod
def get_notification_recipients():
"""
Get distinct users that are local custodian of a project or a contract.
"""

return (
get_user_model()
.objects.filter(Q(project_set__isnull=False) | Q(contracts__isnull=False))
.distinct()
)

@classmethod
def make_notifications_for_user(
cls, day_offset: timedelta, exec_date: datetime.date, user: "User"
):
docs = set()
[docs.update(p.legal_documents.all()) for p in user.project_set.all()]
[docs.update(c.legal_documents.all()) for c in user.contracts.all()]
# Also add all the documents of all contracts of all projects to address the indirect LCs of parent projects
for p in user.project_set.all():
_ = [docs.update(c.legal_documents.all()) for c in p.contracts.all()]

for doc in docs:
if doc.expiry_date and doc.expiry_date - day_offset == exec_date:
cls.notify(user, doc, NotificationVerb.expire)

@staticmethod
def notify(user: "User", obj: "Document", verb: "NotificationVerb"):
"""
Notifies concerning users about the entity.
"""
offset = user.notification_setting.notification_offset
dispatch_by_email = user.notification_setting.send_email
dispatch_in_app = user.notification_setting.send_in_app

msg = f"The Document {obj.shortname} is expiring in {offset} days."
on = obj.expiry_date

logger.info(f"Creating a notification for {user} : {msg}")

Notification.objects.create(
recipient=user,
verb=verb,
message=msg,
on=on,
dispatch_by_email=dispatch_by_email,
dispatch_in_app=dispatch_in_app,
content_type=ContentType.objects.get_for_model(obj),
object_id=obj.id,
).save()


@receiver(post_delete, sender=Document, dispatch_uid="document_delete")
def document_cleanup(sender, instance, **kwargs):
Expand Down
Loading
Loading