diff --git a/.flake8 b/.flake8 index 36c18536a..174a4feea 100644 --- a/.flake8 +++ b/.flake8 @@ -11,6 +11,7 @@ exclude = conftest.py env .env + researchers/tests.py api/ bclabels/ communities/ @@ -20,6 +21,6 @@ exclude = localcontexts/ projects/ tklabels/ - researchers/ backups/ + serviceproviders/ \ No newline at end of file diff --git a/.github/workflows/run_testcases.yml b/.github/workflows/run_testcases.yml index 36da0eef0..eae3b159f 100644 --- a/.github/workflows/run_testcases.yml +++ b/.github/workflows/run_testcases.yml @@ -46,6 +46,7 @@ jobs: DB_USER: ${{ secrets.TEST_DB_USER }} DB_PASS: ${{ secrets.TEST_DB_PASS }} DB_HOST: ${{ secrets.TEST_DB_HOST }} + SF_VALID_USER_IDS: ${{ secrets.SF_VALID_USER_IDS }} DB_PORT: ${{ secrets.TEST_DB_PORT }} CSRF_COOKIE_DOMAIN: ${{ secrets.CSRF_COOKIE_DOMAIN }} EMAIL_HOST: ${{ secrets.EMAIL_HOST }} diff --git a/accounts/forms.py b/accounts/forms.py index b76118447..2ee3e55b0 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -5,6 +5,7 @@ from django.contrib.sites.shortcuts import get_current_site from django.utils.encoding import force_bytes from django.utils.http import urlsafe_base64_encode +from django.core.exceptions import ValidationError from django.utils.translation import gettext_lazy as _ from helpers.emails import send_password_reset_email @@ -17,15 +18,15 @@ class RegistrationForm(UserCreationForm): class Meta: model = User - fields = ['username', 'email', 'password1', 'password2'] + fields = ["username", "email", "password1", "password2"] widgets = { - 'email': forms.EmailInput(attrs={'class': 'w-100'}), + "email": forms.EmailInput(attrs={"class": "w-100"}), } def save(self, commit=True): user = super(RegistrationForm, self).save(commit=False) # Cleans the data so nothing harmful can get passed though the form - user.email = self.cleaned_data['email'] + user.email = self.cleaned_data["email"] # if we want to save if commit: @@ -38,7 +39,20 @@ class UserCreateProfileForm(forms.ModelForm): class Meta: model = User - fields = ['first_name', 'last_name'] + fields = ["first_name", "last_name"] + widgets = { + "first_name": forms.TextInput( + attrs={ + "class": "w-100", + "autocomplete": "off", + "required": True + }), + "last_name": forms.TextInput( + attrs={ + "class": "w-100", + "autocomplete": "off" + }) + } # updating user instance (same as above but includes email) @@ -46,21 +60,23 @@ class UserUpdateForm(forms.ModelForm): class Meta: model = User - fields = ['email', 'first_name', 'last_name'] + fields = ["email", "first_name", "last_name"] widgets = { - 'email': forms.EmailInput(attrs={'class': 'w-100'}), - 'first_name': forms.TextInput(attrs={'class': 'w-100'}), - 'last_name': forms.TextInput(attrs={'class': 'w-100'}), + "email": forms.EmailInput(attrs={"class": "w-100"}), + "first_name": forms.TextInput( + attrs={"class": "w-100", "required": True} + ), + "last_name": forms.TextInput(attrs={"class": "w-100"}), } def clean(self): super(UserUpdateForm, self).clean() - email = self.cleaned_data.get('email') + email = self.cleaned_data.get("email") user_id = self.instance.id if self.instance else None if len(email) == 0: - self._errors['email'] = self.error_class(['Email Is Required']) + self._errors["email"] = self.error_class(["Email Is Required"]) elif User.objects.filter(email=email).exclude(id=user_id).exists(): - self._errors['email'] = self.error_class(["Email already exists."]) + self._errors["email"] = self.error_class(["Email already exists."]) return self.cleaned_data @@ -70,10 +86,10 @@ class Meta: model = Profile fields = ['position', 'affiliation', 'city_town', 'state_province_region', 'country'] widgets = { - 'position': forms.TextInput(attrs={'style': 'width: 100%;'}), - 'affiliation': forms.TextInput(attrs={'class': 'w-100'}), - 'city_town': forms.TextInput(attrs={'class': 'w-100'}), - 'state_province_region': forms.TextInput(attrs={'class': 'w-100'}), + "position": forms.TextInput(attrs={"style": "width: 100%;"}), + "affiliation": forms.TextInput(attrs={"class": "w-100"}), + "city_town": forms.TextInput(attrs={"class": "w-100"}), + "state_province_region": forms.TextInput(attrs={"class": "w-100"}), } @@ -86,37 +102,33 @@ class Meta: 'state_province_region', 'country' ] widgets = { - 'position': forms.TextInput(attrs={'class': 'w-100'}), - 'affiliation': forms.TextInput(attrs={'class': 'w-100'}), - 'preferred_language': forms.TextInput(attrs={'class': 'w-100'}), - 'languages_spoken': forms.TextInput(attrs={'class': 'w-100'}), - 'city_town': forms.TextInput(attrs={'class': 'w-100'}), - 'state_province_region': forms.TextInput(attrs={'class': 'w-100'}), + "position": forms.TextInput(attrs={"class": "w-100"}), + "affiliation": forms.TextInput(attrs={"class": "w-100"}), + "preferred_language": forms.TextInput(attrs={"class": "w-100"}), + "languages_spoken": forms.TextInput(attrs={"class": "w-100"}), + "city_town": forms.TextInput(attrs={"class": "w-100"}), + "state_province_region": forms.TextInput(attrs={"class": "w-100"}), } class ResendEmailActivationForm(forms.Form): email = forms.EmailField( - label=_('Email'), + label=_("Email"), required=True, widget=forms.EmailInput(attrs={ 'class': 'w-100', 'placeholder': 'email@domain.com' - }) - ) + })) class SignUpInvitationForm(forms.ModelForm): class Meta: model = SignUpInvitation - fields = ['email', 'message'] + fields = ["email", "message"] widgets = { - 'message': forms.Textarea(attrs={ - 'rows': 4, - 'cols': 65 - }), - 'email': forms.EmailInput(attrs={'size': 65}), + "message": forms.Textarea(attrs={"rows": 4, "cols": 65}), + "email": forms.EmailInput(attrs={"size": 65}), } @@ -128,7 +140,7 @@ class ContactOrganizationForm(forms.Form): }) ) email = forms.EmailField( - label=_('Email Address'), + label=_("Email Address"), required=True, widget=forms.EmailInput(attrs={ 'class': 'w-100', @@ -173,13 +185,99 @@ def save( for user in self.get_users(email): user_email = getattr(user, email_field_name) context = { - 'email': user_email, - 'domain': domain, - 'site_name': site_name, - 'uid': urlsafe_base64_encode(force_bytes(user.pk)), - 'user': user, - 'token': token_generator.make_token(user), - 'protocol': 'https' if use_https else 'http', + "email": user_email, + "domain": domain, + "site_name": site_name, + "uid": urlsafe_base64_encode(force_bytes(user.pk)), + "user": user, + "token": token_generator.make_token(user), + "protocol": "https" if use_https else "http", **(extra_email_context or {}), } send_password_reset_email(request, context) + + +class SubscriptionForm(forms.Form): + ACCOUNT_TYPE_CHOICES = ( + ("", "Please select account type..."), + ("institution_account", "Institution Account"), + ("community_account", "Community Account"), + ("researcher_account", "Researcher Account"), + ("service_provider_account", "Service Provider Account"), + ) + INQUIRY_TYPE_CHOICES = ( + ("", "Please select inquiry type..."), + ("Subscription", "Subscription"), + ("Membership", "Membership"), + ("Service Provider", "Service Provider"), + ("Subscription -- CC Notices Only", "CC Notices Only"), + ("Something Else", "Something Else"), + ("Not Sure", "Not Sure"), + ) + first_name = forms.CharField( + widget=forms.TextInput( + attrs={ + "class": "w-100", + "autocomplete": "off", + } + ) + ) + last_name = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={ + "class": "w-100", + "autocomplete": "off", + } + ), + ) + email = forms.EmailField( + widget=forms.EmailInput( + attrs={ + "class": "w-100", + "autocomplete": "off", + } + ) + ) + account_type = forms.ChoiceField( + choices=ACCOUNT_TYPE_CHOICES, + widget=forms.Select( + attrs={ + "class": "w-100", + "autocomplete": "off", + "placeholder": "Please select account type...", + } + ), + ) + inquiry_type = forms.ChoiceField( + choices=INQUIRY_TYPE_CHOICES, + widget=forms.Select( + attrs={ + "class": "w-100", + "autocomplete": "off", + "placeholder": "Please select inquiry type...", + } + ), + ) + organization_name = forms.CharField( + required=False, + widget=forms.TextInput( + attrs={ + "id": "organizationInput", + "class": "w-100", + "autocomplete": "off", + } + ) + ) + + def clean_account_type(self): + account_type = self.cleaned_data.get("account_type") + if not account_type: + raise ValidationError("Please select an account type.") + return account_type + + def clean_inquiry_type(self): + inquiry_type = self.cleaned_data.get("inquiry_type") + if not inquiry_type: + raise ValidationError("Please select an inquiry type.") + return inquiry_type diff --git a/accounts/migrations/0020_subscription.py b/accounts/migrations/0020_subscription.py new file mode 100644 index 000000000..b96d14ab5 --- /dev/null +++ b/accounts/migrations/0020_subscription.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2 on 2024-03-11 12:57 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('communities', '0050_merge_0048_auto_20240123_0046_0049_alter_boundary_id'), + ('institutions', '0031_alter_institution_id'), + ('researchers', '0036_alter_researcher_id'), + ('accounts', '0019_auto_20231220_1706'), + ] + + operations = [ + migrations.CreateModel( + name='Subscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('users_count', models.IntegerField()), + ('api_key_count', models.IntegerField()), + ('project_count', models.IntegerField()), + ('notification_count', models.IntegerField()), + ('is_subscribed', models.BooleanField(default=False)), + ('community', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subscribed_community', to='communities.community')), + ('institution', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subscribed_institution', to='institutions.institution')), + ('researcher', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subscribed_researcher', to='researchers.researcher')), + ], + ), + ] diff --git a/accounts/migrations/0021_subscription_date_last_updated_subscription_end_date_and_more.py b/accounts/migrations/0021_subscription_date_last_updated_subscription_end_date_and_more.py new file mode 100644 index 000000000..a36431bc2 --- /dev/null +++ b/accounts/migrations/0021_subscription_date_last_updated_subscription_end_date_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.11 on 2024-03-22 11:16 + +from django.db import migrations, models +import django.utils.timezone + + +class Migration(migrations.Migration): + dependencies = [ + ("accounts", "0020_subscription"), + ] + + operations = [ + migrations.AddField( + model_name="subscription", + name="date_last_updated", + field=models.DateTimeField(auto_now=True), + ), + migrations.AddField( + model_name="subscription", + name="end_date", + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name="subscription", + name="start_date", + field=models.DateTimeField(default=django.utils.timezone.now), + ), + ] diff --git a/accounts/migrations/0022_remove_subscription_is_subscribed.py b/accounts/migrations/0022_remove_subscription_is_subscribed.py new file mode 100644 index 000000000..c8328f25e --- /dev/null +++ b/accounts/migrations/0022_remove_subscription_is_subscribed.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.11 on 2024-03-22 14:33 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ( + "accounts", + "0021_subscription_date_last_updated_subscription_end_date_and_more", + ), + ] + + operations = [ + migrations.RemoveField( + model_name="subscription", + name="is_subscribed", + ), + ] diff --git a/accounts/migrations/0023_auto_20240408_1250.py b/accounts/migrations/0023_auto_20240408_1250.py new file mode 100644 index 000000000..5f840d6bd --- /dev/null +++ b/accounts/migrations/0023_auto_20240408_1250.py @@ -0,0 +1,33 @@ +# Generated by Django 3.2 on 2024-04-08 16:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0022_remove_subscription_is_subscribed'), + ] + + operations = [ + migrations.AlterField( + model_name='subscription', + name='api_key_count', + field=models.IntegerField(help_text='For unlimited counts the value shoud be -1'), + ), + migrations.AlterField( + model_name='subscription', + name='notification_count', + field=models.IntegerField(help_text='For unlimited counts the value shoud be -1'), + ), + migrations.AlterField( + model_name='subscription', + name='project_count', + field=models.IntegerField(help_text='For unlimited counts the value shoud be -1'), + ), + migrations.AlterField( + model_name='subscription', + name='users_count', + field=models.IntegerField(help_text='For unlimited counts the value shoud be -1'), + ), + ] diff --git a/accounts/migrations/0023_auto_20240411_1850.py b/accounts/migrations/0023_auto_20240411_1850.py new file mode 100644 index 000000000..68a3e6e65 --- /dev/null +++ b/accounts/migrations/0023_auto_20240411_1850.py @@ -0,0 +1,52 @@ +# Generated by Django 3.2 on 2024-04-11 18:50 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('institutions', '0032_institution_is_subscribed'), + ('researchers', '0036_alter_researcher_id'), + ('communities', '0050_merge_0048_auto_20240123_0046_0049_alter_boundary_id'), + ('accounts', '0022_remove_subscription_is_subscribed'), + ] + + operations = [ + migrations.AlterField( + model_name='subscription', + name='api_key_count', + field=models.IntegerField(help_text='For unlimited counts the value shoud be -1'), + ), + migrations.AlterField( + model_name='subscription', + name='community', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subscribed_community', to='communities.community'), + ), + migrations.AlterField( + model_name='subscription', + name='institution', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subscribed_institution', to='institutions.institution'), + ), + migrations.AlterField( + model_name='subscription', + name='notification_count', + field=models.IntegerField(help_text='For unlimited counts the value shoud be -1'), + ), + migrations.AlterField( + model_name='subscription', + name='project_count', + field=models.IntegerField(help_text='For unlimited counts the value shoud be -1'), + ), + migrations.AlterField( + model_name='subscription', + name='researcher', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subscribed_researcher', to='researchers.researcher'), + ), + migrations.AlterField( + model_name='subscription', + name='users_count', + field=models.IntegerField(help_text='For unlimited counts the value shoud be -1'), + ), + ] diff --git a/accounts/migrations/0024_merge_0023_auto_20240408_1250_0023_auto_20240411_1850.py b/accounts/migrations/0024_merge_0023_auto_20240408_1250_0023_auto_20240411_1850.py new file mode 100644 index 000000000..a235d3be6 --- /dev/null +++ b/accounts/migrations/0024_merge_0023_auto_20240408_1250_0023_auto_20240411_1850.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2 on 2024-04-12 17:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0023_auto_20240408_1250'), + ('accounts', '0023_auto_20240411_1850'), + ] + + operations = [ + ] diff --git a/accounts/migrations/0025_merge_20240507_0954.py b/accounts/migrations/0025_merge_20240507_0954.py new file mode 100644 index 000000000..91fc4c4c5 --- /dev/null +++ b/accounts/migrations/0025_merge_20240507_0954.py @@ -0,0 +1,13 @@ +# Generated by Django 4.2 on 2024-05-07 09:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("accounts", "0020_inactiveuser"), + ("accounts", "0024_merge_0023_auto_20240408_1250_0023_auto_20240411_1850"), + ] + + operations = [] diff --git a/accounts/migrations/0026_subscription_service_provider_and_more.py b/accounts/migrations/0026_subscription_service_provider_and_more.py new file mode 100644 index 000000000..6f64dc906 --- /dev/null +++ b/accounts/migrations/0026_subscription_service_provider_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.6 on 2024-06-24 16:08 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0025_merge_20240507_0954'), + ('serviceproviders', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='subscription', + name='service_provider', + field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='subscribed_service_provider', to='serviceproviders.serviceprovider'), + ), + migrations.AddField( + model_name='useraffiliation', + name='service_providers', + field=models.ManyToManyField(blank=True, related_name='user_service_providers', to='serviceproviders.serviceprovider'), + ), + ] diff --git a/accounts/migrations/0027_serviceproviderconnections.py b/accounts/migrations/0027_serviceproviderconnections.py new file mode 100644 index 000000000..096bd0884 --- /dev/null +++ b/accounts/migrations/0027_serviceproviderconnections.py @@ -0,0 +1,33 @@ +# Generated by Django 5.0.6 on 2024-08-07 18:32 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0026_subscription_service_provider_and_more'), + ('communities', '0053_remove_invitemember_communities_sender__b17605_idx_and_more'), + ('institutions', '0035_remove_institution_is_submitted'), + ('researchers', '0039_remove_researcher_is_submitted'), + ('serviceproviders', '0003_serviceprovider_show_connections'), + ] + + operations = [ + migrations.CreateModel( + name='ServiceProviderConnections', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('communities', models.ManyToManyField(blank=True, related_name='service_provider_communities', to='communities.community')), + ('institutions', models.ManyToManyField(blank=True, related_name='service_provider_institutions', to='institutions.institution')), + ('researchers', models.ManyToManyField(blank=True, related_name='service_provider_researchers', to='researchers.researcher')), + ('service_provider', models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='service_provider_connection', to='serviceproviders.serviceprovider')), + ], + options={ + 'verbose_name': 'Service Provider Connection', + 'verbose_name_plural': 'Service Provider Connections', + 'indexes': [models.Index(fields=['service_provider'], name='accounts_se_service_533d9c_idx')], + }, + ), + ] diff --git a/accounts/migrations/0028_subscription_subscription_type.py b/accounts/migrations/0028_subscription_subscription_type.py new file mode 100644 index 000000000..b5eb08a56 --- /dev/null +++ b/accounts/migrations/0028_subscription_subscription_type.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.6 on 2024-08-23 17:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0027_serviceproviderconnections'), + ] + + operations = [ + migrations.AddField( + model_name='subscription', + name='subscription_type', + field=models.CharField(choices=[('individual', 'Individual'), ('small', 'Small'), ('medium', 'Medium'), ('large', 'Large'), ('cc_notice_only', 'CC Notice Only'), ('cc_notices', 'CC Notices')], default='Not-Set', max_length=20), + preserve_default=False, + ), + ] diff --git a/accounts/migrations/0029_alter_subscription_subscription_type.py b/accounts/migrations/0029_alter_subscription_subscription_type.py new file mode 100644 index 000000000..28cd2d265 --- /dev/null +++ b/accounts/migrations/0029_alter_subscription_subscription_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-09-10 12:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0028_subscription_subscription_type'), + ] + + operations = [ + migrations.AlterField( + model_name='subscription', + name='subscription_type', + field=models.CharField(choices=[('individual', 'Individual'), ('small', 'Small'), ('medium', 'Medium'), ('large', 'Large'), ('cc_notice_only', 'CC Notice Only'), ('cc_notices', 'CC Notices'), ('member', 'Member'), ('service_provide', 'Service Provider'), ('founding_supporter', 'Founding Supporter')], max_length=20), + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 528bdd927..d50293b92 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,9 +1,13 @@ from django.contrib.auth.models import User from django.db import models from django_countries.fields import CountryField +from django.utils import timezone +from django.core.exceptions import ValidationError from communities.models import Community from institutions.models import Institution +from researchers.models import Researcher +from serviceproviders.models import ServiceProvider class Profile(models.Model): @@ -36,14 +40,14 @@ class Profile(models.Model): def get_location(self): components = [self.city_town, self.state_province_region, self.country.name] - location = ', '.join(filter(None, components)) or 'None specified' + location = ', '.join(filter(None, components)) or None return location def __str__(self): return str(self.user) class Meta: - indexes = [models.Index(fields=['user'])] + indexes = [models.Index(fields=["user"])] class UserAffiliation(models.Model): @@ -58,6 +62,9 @@ class UserAffiliation(models.Model): institutions = models.ManyToManyField( Institution, blank=True, related_name="user_institutions" ) + service_providers = models.ManyToManyField( + ServiceProvider, blank=True, related_name="user_service_providers" + ) @classmethod def create(cls, user): @@ -68,9 +75,9 @@ def __str__(self): return str(self.user) class Meta: - indexes = [models.Index(fields=['user'])] - verbose_name = 'User Affiliation' - verbose_name_plural = 'User Affiliations' + indexes = [models.Index(fields=["user"])] + verbose_name = "User Affiliation" + verbose_name_plural = "User Affiliations" class SignUpInvitation(models.Model): @@ -85,7 +92,89 @@ def __str__(self): class Meta: verbose_name = "Sign Up Invitation" verbose_name_plural = "Sign Up Invitations" - ordering = ('-date_sent', ) + ordering = ("-date_sent",) + + +class Subscription(models.Model): + SUBSCRIPTION_CHOICES = [ + ('individual', 'Individual'), + ('small', 'Small'), + ('medium', 'Medium'), + ('large', 'Large'), + ('cc_notice_only', 'CC Notice Only'), + ('cc_notices', 'CC Notices'), + ('member', 'Member'), + ('service_provide', 'Service Provider'), + ('founding_supporter', 'Founding Supporter') + ] + + institution = models.ForeignKey( + Institution, + on_delete=models.CASCADE, + default=None, + null=True, + related_name="subscribed_institution", + blank=True, + ) + community = models.ForeignKey( + Community, + on_delete=models.CASCADE, + default=None, + null=True, + related_name="subscribed_community", + blank=True, + ) + researcher = models.ForeignKey( + Researcher, + on_delete=models.CASCADE, + default=None, + null=True, + related_name="subscribed_researcher", + blank=True, + ) + service_provider = models.ForeignKey( + ServiceProvider, + on_delete=models.CASCADE, + default=None, + null=True, + related_name="subscribed_service_provider", + blank=True, + ) + users_count = models.IntegerField( + help_text="For unlimited counts the value shoud be -1" + ) + api_key_count = models.IntegerField( + help_text="For unlimited counts the value shoud be -1" + ) + project_count = models.IntegerField( + help_text="For unlimited counts the value shoud be -1" + ) + notification_count = models.IntegerField( + help_text="For unlimited counts the value shoud be -1" + ) + start_date = models.DateTimeField(default=timezone.now) + end_date = models.DateTimeField(blank=True, null=True) + date_last_updated = models.DateTimeField(auto_now=True) + subscription_type = models.CharField(max_length=20, choices=SUBSCRIPTION_CHOICES) + + def clean(self): + count = sum([ + bool(self.institution_id), + bool(self.community_id), + bool(self.researcher_id), + bool(self.service_provider_id) + ]) + if count != 1: + errormsg = "Exactly one of institution, community, " \ + "researcher, service provider should be present." + raise ValidationError(errormsg) + + super().clean() + + def save(self, *args, **kwargs): + self.full_clean() + super().save(*args, **kwargs) + # ordering = ('-date_sent', ) class InactiveUser(models.Model): @@ -96,3 +185,27 @@ class InactiveUser(models.Model): def __str__(self): return self.username + + +class ServiceProviderConnections(models.Model): + service_provider = models.ForeignKey( + ServiceProvider, on_delete=models.CASCADE, default=None, null=True, + related_name="service_provider_connection" + ) + communities = models.ManyToManyField( + Community, blank=True, related_name="service_provider_communities" + ) + institutions = models.ManyToManyField( + Institution, blank=True, related_name="service_provider_institutions" + ) + researchers = models.ManyToManyField( + Researcher, blank=True, related_name="service_provider_researchers" + ) + + def __str__(self): + return str(self.service_provider) + + class Meta: + indexes = [models.Index(fields=["service_provider"])] + verbose_name = "Service Provider Connection" + verbose_name_plural = "Service Provider Connections" diff --git a/accounts/templatetags/custom_acct_tags.py b/accounts/templatetags/custom_acct_tags.py index 7d1fa53ee..e627463d7 100644 --- a/accounts/templatetags/custom_acct_tags.py +++ b/accounts/templatetags/custom_acct_tags.py @@ -1,11 +1,14 @@ from django import template -from django.db.models import Q +from django.db.models import Q, Count +from itertools import chain from accounts.utils import get_users_name from communities.models import Community, JoinRequest from institutions.models import Institution -from projects.models import ProjectCreator +from projects.models import ProjectCreator, Project from researchers.models import Researcher +from serviceproviders.models import ServiceProvider +from accounts.models import ServiceProviderConnections register = template.Library() @@ -34,19 +37,19 @@ def community_count(): @register.simple_tag def institution_count(): - return Institution.approved.count() + return Institution.subscribed.count() @register.simple_tag def researcher_count(): - return Researcher.objects.count() + return Researcher.objects.filter(is_subscribed=True).count() @register.simple_tag def all_account_count(): c = Community.approved.count() - i = Institution.approved.count() - r = Researcher.objects.count() + i = Institution.subscribed.count() + r = Researcher.objects.filter(is_subscribed=True).count() total = c + i + r return total @@ -79,6 +82,21 @@ def is_user_member(account, user): return account.is_user_in_institution(user) if isinstance(account, Community): return account.is_user_in_community(user) + if isinstance(account, ServiceProvider): + return account.is_user_in_service_provider(user) + + +@register.simple_tag +def is_connected_service_provider(sp_account, account): + if isinstance(account, Institution): + return ServiceProviderConnections.objects.filter( + institutions=account, service_provider=sp_account).exists() + if isinstance(account, Community): + return ServiceProviderConnections.objects.filter( + communities=account, service_provider=sp_account).exists() + if isinstance(account, Researcher): + return ServiceProviderConnections.objects.filter( + researchers=account, service_provider=sp_account).exists() @register.simple_tag @@ -103,3 +121,135 @@ def user_created_project_as_researcher(user_id: int, researcher_id: int) -> bool researcher=researcher_id, project__project_creator=user_id ).exists() + + +@register.simple_tag +def account_count_cards(account): + ''' + For Project Lists: + 1. account projects + + 2. projects account has been notified of + 3. projects where account is contributor + + Counts: + 1. Projects with Labels + 2. Projects with Notices + 3. Projects with Connections other than the account itself + ''' + + if isinstance(account, Institution): + projects_list = list(chain( + account.institution_created_project.all().values_list( + 'project__unique_id', flat=True + ), + account.institutions_notified.all().values_list( + 'project__unique_id', flat=True + ), + account.contributing_institutions.all().values_list( + 'project__unique_id', flat=True + ), + )) + project_ids = list(set(projects_list)) # remove duplicate ids + projects = Project.objects.filter(unique_id__in=project_ids) + + labels_count = projects.filter( + Q(bc_labels__isnull=False) | Q(tk_labels__isnull=False) + ).distinct().count() + + notices_count = projects.filter(project_notice__archived=False).distinct().count() + + connections_count = projects.annotate( + institution_count=Count('project_contributors__institutions') + ).exclude( + Q(project_contributors__communities=None) & + Q(project_contributors__researchers=None) & + Q(institution_count=1) + ).distinct().count() + + elif isinstance(account, Researcher): + projects_list = list(chain( + account.researcher_created_project.all().values_list( + 'project__unique_id', flat=True + ), + account.researchers_notified.all().values_list( + 'project__unique_id', flat=True + ), + account.contributing_researchers.all().values_list( + 'project__unique_id', flat=True + ), + )) + project_ids = list(set(projects_list)) + projects = Project.objects.filter(unique_id__in=project_ids) + + labels_count = projects.filter( + Q(bc_labels__isnull=False) | Q(tk_labels__isnull=False) + ).distinct().count() + + notices_count = projects.filter(project_notice__archived=False).distinct().count() + + connections_count = projects.annotate( + researcher_count=Count('project_contributors__researchers') + ).exclude( + Q(project_contributors__communities=None) & + Q(project_contributors__institutions=None) & + Q(researcher_count=1) + ).distinct().count() + + elif isinstance(account, Community): + projects_list = list(chain( + account.community_created_project.all().values_list( + 'project__unique_id', flat=True + ), + account.communities_notified.all().values_list( + 'project__unique_id', flat=True + ), + account.contributing_communities.all().values_list( + 'project__unique_id', flat=True + ), + )) + project_ids = list(set(projects_list)) + projects = Project.objects.filter(unique_id__in=project_ids) + + labels_count = projects.filter( + Q(bc_labels__isnull=False) | Q(tk_labels__isnull=False) + ).distinct().count() + + notices_count = projects.filter(project_notice__archived=False).distinct().count() + + connections_count = projects.annotate( + community_count=Count('project_contributors__communities') + ).exclude( + Q(project_contributors__researchers=None) & + Q(project_contributors__institutions=None) & + Q(community_count=1) + ).distinct().count() + + elif isinstance(account, ServiceProvider): + try: + institutions = ServiceProviderConnections.objects.filter( + service_provider=account + ).annotate(institution_count=Count('institutions')).values_list( + 'institution_count', flat=True + ).first() + communities = ServiceProviderConnections.objects.filter( + service_provider=account + ).annotate(community_count=Count('communities')).values_list( + 'community_count', flat=True + ).first() + researchers = ServiceProviderConnections.objects.filter( + service_provider=account + ).annotate(researcher_count=Count('researchers')).values_list( + 'researcher_count', flat=True + ).first() + connections_count = institutions + communities + researchers + + except Exception: + return {'connections': 0} + + return {'connections': connections_count} + + return { + 'labels': labels_count, + 'notices': notices_count, + 'connections': connections_count + } diff --git a/accounts/urls.py b/accounts/urls.py index a5dcf5338..cd0b37a15 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -14,6 +14,11 @@ views.CustomSocialConnectionsView.as_view(), name='socialaccount_connections' ), + path( + "subscription-inquiry/", + views.subscription_inquiry, + name="subscription-inquiry", + ), path('activate//', views.ActivateAccountView.as_view(), name='activate'), path('verify/', views.verify, name='verify'), path('invite/', views.invite_user, name='invite'), @@ -71,5 +76,9 @@ ), name="password_reset_complete" ), - path('apikey/', views.api_keys, name='api-key'), + path( + "subscription///", + views.subscription, + name="subscription" + ), ] diff --git a/accounts/utils.py b/accounts/utils.py index 3ad9fff45..ea5309d8f 100644 --- a/accounts/utils.py +++ b/accounts/utils.py @@ -3,12 +3,16 @@ from django.conf import settings from django.contrib.auth.models import User from django.utils.http import url_has_allowed_host_and_scheme +from django.shortcuts import render, redirect +from django.urls import reverse from unidecode import unidecode +from django.contrib import messages from accounts.models import UserAffiliation from communities.models import Community from institutions.models import Institution from researchers.models import Researcher +from serviceproviders.models import ServiceProvider def get_users_name(user): @@ -18,31 +22,37 @@ def get_users_name(user): def manage_mailing_list(request, first_name, emailb64): - selections = request.POST.getlist('topic') - tech = 'no' - news = 'no' - events = 'no' - notice = 'no' - labels = 'no' + selections = request.POST.getlist("topic") + tech = "no" + news = "no" + events = "no" + notice = "no" + labels = "no" for item in selections: - if item == 'tech': - tech = 'yes' - if item == 'news': - news = 'yes' - if item == 'events': - events = 'yes' - if item == 'notice': - notice = 'yes' - if item == 'labels': - labels = 'yes' - variables = '{"first_name":"%s", "tech": "%s", "news": "%s", ' \ - '"events": "%s","notice": "%s","labels": "%s", ' \ - '"id": "%s"}' % ( - first_name, tech, news, events, notice, labels, emailb64) + if item == "tech": + tech = "yes" + if item == "news": + news = "yes" + if item == "events": + events = "yes" + if item == "notice": + notice = "yes" + if item == "labels": + labels = "yes" + variables = ( + '{"first_name":"%s", "tech": "%s", "news": "%s", ' + '"events": "%s","notice": "%s","labels": "%s", ' + '"id": "%s"}' + % (first_name, tech, news, events, notice, labels, emailb64) + ) return variables -def return_registry_accounts(community_accounts, researcher_accounts, institution_accounts): +def return_registry_accounts( + community_accounts, researcher_accounts, institution_accounts, + service_provider_accounts + ): + combined_accounts = [] if community_accounts is not None: @@ -50,15 +60,20 @@ def return_registry_accounts(community_accounts, researcher_accounts, institutio combined_accounts.extend(researcher_accounts) combined_accounts.extend(institution_accounts) + combined_accounts.extend(service_provider_accounts) cards = sorted( combined_accounts, key=lambda obj: ( - unidecode(obj.community_name.lower().strip()) - if isinstance(obj, Community) else unidecode(obj.institution_name.lower().strip()) - if isinstance(obj, Institution) else unidecode(obj.user.first_name.lower().strip()) - if isinstance(obj, Researcher) and obj.user.first_name.strip() else - unidecode(obj.user.username.lower().strip()) if isinstance(obj, Researcher) else '' + unidecode(obj.community_name.lower().strip()) if isinstance(obj, Community) else + unidecode(obj.institution_name.lower().strip()) if isinstance(obj, Institution) else + unidecode(obj.name.lower().strip()) if isinstance(obj, ServiceProvider) else + ( + unidecode(obj.user.first_name.lower().strip()) + if isinstance(obj, Researcher) and obj.user.first_name.strip() else + unidecode(obj.user.username.lower().strip()) if isinstance(obj, Researcher) + else '' + ) ) ) @@ -66,7 +81,7 @@ def return_registry_accounts(community_accounts, researcher_accounts, institutio def get_next_path(request, default_path: str): - next_path = request.POST.get('next') + next_path = request.POST.get("next") # validate next_path exists and is not an open redirect if next_path and url_has_allowed_host_and_scheme(next_path, settings.ALLOWED_HOSTS): @@ -158,3 +173,115 @@ def determine_user_role(user: User) -> str: return 'is_member' return 'default' + + +def institute_account_subscription( + request, institution, account_exist, form, non_ror_institutes +): + from helpers.utils import create_salesforce_account_or_lead + + if institution and account_exist: + if institution.institution_creator == account_exist: + messages.add_message( + request, + messages.INFO, + "This account already exists. Please login to view or join the account.", + ) + return redirect("dashboard") + elif account_exist and institution: + next_url = reverse( + "public-institution", kwargs={"pk": institution.id} + ) + login_url = f"/login/?next={next_url}" + return render( + request, + "accounts/subscription-inquiry.html", + { + "form": form, + "login_url": login_url, + "institution": institution, + }, + ) + elif account_exist and not institution: + messages.add_message( + request, + messages.INFO, + "This account already exists. Please login to view or join the account.", + ) + return redirect("select-account") + elif institution and not account_exist: + return render( + request, + "accounts/subscription-inquiry.html", + { + "form": form, + "non_ror_institutes": non_ror_institutes, + "institution": institution, + }, + ) + else: + if create_salesforce_account_or_lead(request, data=form.cleaned_data): + messages.add_message( + request, + messages.INFO, + ( + "Thank you for your submission, our team will review and be in " + "contact with the subscription contact. You will be notified once your " + "subscription has been processed." + ), + ) + return redirect("subscription-inquiry") + else: + messages.add_message( + request, + messages.ERROR, + "An unexpected error has occurred. " + "Please contact support@localcontexts.org.", + ) + return redirect("subscription-inquiry") + + +def confirm_subscription(request, user, form, account_type): + from helpers.utils import create_salesforce_account_or_lead + if account_type == "institution_account": + hub_id = str(user.id) + "_i" + isbusiness = True + elif account_type == "researcher_account": + hub_id = str(user.id) + "_r" + isbusiness = False + elif account_type == "service_provider_account": + hub_id = str(user.id) + "_sp" + isbusiness = True + elif account_type == "community_account": + hub_id = str(user.id) + "_c" + isbusiness = False + else: + raise ValueError("Invalid account type") + + if create_salesforce_account_or_lead( + request, + hubId=hub_id, + data=form.cleaned_data, + isbusiness=isbusiness + ): + messages.add_message( + request, + messages.INFO, + "Thank you for your interest! Our team will review and be in contact soon." + ) + else: + messages.add_message( + request, + messages.ERROR, + "An unexpected error has occurred. Please contact support@localcontexts.org.") + return redirect('dashboard') + + +def escape_single_quotes(data): + if isinstance(data, dict): + return {key: escape_single_quotes(value) for key, value in data.items()} + elif isinstance(data, list): + return [escape_single_quotes(item) for item in data] + elif isinstance(data, str): + return data.replace("'", "\\'") + return data diff --git a/accounts/views.py b/accounts/views.py index 03489a894..0d18de4e9 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,17 +1,19 @@ # Captcha validation imports import urllib - from allauth.socialaccount.models import SocialAccount from allauth.socialaccount.views import ConnectionsView, SignupView + # For emails from django.conf import settings from django.contrib import auth, messages from django.contrib.auth import update_session_auth_hash from django.contrib.auth.decorators import login_required +from django.utils import timezone from django.contrib.auth.models import User from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.contrib.auth.views import (PasswordChangeForm, PasswordResetView, SetPasswordForm) from django.contrib.sites.shortcuts import get_current_site +from django.core import serializers from django.core.paginator import Paginator from django.db.models import Q from django.http import Http404, HttpResponseRedirect, HttpResponseForbidden @@ -22,10 +24,7 @@ from django.utils.safestring import mark_safe from django.views.decorators.csrf import csrf_protect from django.views.generic import View -from maintenance_mode.decorators import force_maintenance_mode_off -from rest_framework_api_key.models import APIKey from unidecode import unidecode -from communities.models import Community, InviteMember from helpers.emails import ( add_to_newsletter_mailing_list, generate_token, get_newsletter_member_info, @@ -33,23 +32,34 @@ send_invite_user_email, send_welcome_email, unsubscribe_from_mailing_list, add_to_active_users_mailing_list, remove_from_active_users_mailing_list ) -from helpers.models import HubActivity -from helpers.utils import (accept_member_invite, validate_email, validate_recaptcha) -from institutions.models import Institution +from .forms import ( + RegistrationForm, ResendEmailActivationForm, CustomPasswordResetForm, UserCreateProfileForm, + ProfileCreationForm, UserUpdateForm, ProfileUpdateForm, SignUpInvitationForm, + SubscriptionForm, +) + +from .utils import ( + get_next_path, get_users_name, return_registry_accounts, manage_mailing_list, + institute_account_subscription, escape_single_quotes, determine_user_role, + remove_user_from_account +) +from institutions.utils import get_institution from localcontexts.utils import dev_prod_or_local +from researchers.utils import is_user_researcher +from helpers.utils import ( + accept_member_invite, validate_email, validate_recaptcha, check_member_role +) +from .models import SignUpInvitation, Profile, UserAffiliation, Subscription +from helpers.models import HubActivity from projects.models import Project +from communities.models import InviteMember, Community +from institutions.models import Institution from researchers.models import Researcher -from researchers.utils import is_user_researcher +from serviceproviders.models import ServiceProvider -from .models import SignUpInvitation, Profile, UserAffiliation -from .utils import (get_next_path, get_users_name, return_registry_accounts, - manage_mailing_list, determine_user_role, remove_user_from_account) from .decorators import unauthenticated_user -from .forms import ( - CustomPasswordResetForm, ProfileCreationForm, ProfileUpdateForm, RegistrationForm, - ResendEmailActivationForm, SignUpInvitationForm, UserCreateProfileForm, UserUpdateForm -) +from maintenance_mode.decorators import force_maintenance_mode_off @unauthenticated_user @@ -67,8 +77,8 @@ def register(request): messages.error(request, 'A user with this username already exists.') return redirect('register') elif not validate_email(email=user.email): - messages.error(request, 'The email you entered is invalid') - return redirect('register') + messages.error(request, "The email you entered is invalid.") + return redirect("register") else: user.is_active = False user.save() @@ -82,9 +92,9 @@ def register(request): HubActivity.objects.create(action_user_id=user.id, action_type="New User") return redirect('verify') else: - messages.error(request, 'Invalid reCAPTCHA. Please try again.') + messages.error(request, "Invalid reCAPTCHA. Please try again.") - return redirect('register') + return redirect("register") return render(request, "accounts/register.html", {"form": form}) @@ -94,7 +104,7 @@ def get(self, request, uidb64, token): try: uid = force_str(urlsafe_base64_decode(uidb64)) user = User.objects.get(pk=uid) - except (Exception, ): + except (Exception,): user = None if user is not None and generate_token.check_token(user, token): @@ -114,10 +124,10 @@ def get(self, request, uidb64, token): @unauthenticated_user def verify(request): if not request.user.is_anonymous: - return HttpResponseRedirect('/dashboard') + return HttpResponseRedirect("/dashboard") form = ResendEmailActivationForm(request.POST or None) - if request.method == 'POST': + if request.method == "POST": if form.is_valid(): active_users = User._default_manager.filter( **{ @@ -142,9 +152,9 @@ def verify(request): def login(request): envi = dev_prod_or_local(request.get_host()) - if request.method == 'POST': - username = request.POST.get('username') - password = request.POST.get('password') + if request.method == "POST": + username = request.POST.get("username") + password = request.POST.get("password") user = auth.authenticate(request, username=username, password=password) if user is None: @@ -161,7 +171,7 @@ def login(request): ) # Welcome email send_welcome_email(request, user) - return redirect('create-profile') + return redirect("create-profile") else: auth.login( request, user, backend='django.contrib.auth.backends.ModelBackend' @@ -170,15 +180,14 @@ def login(request): else: if not user.last_login: messages.error( - request, 'Your account is not active yet. ' - 'Please verify your email.' + request, 'Your account is not active yet. Please verify your email.' ) if SignUpInvitation.objects.filter(email=user.email).exists(): for invite in SignUpInvitation.objects.filter(email=user.email): invite.delete() send_activation_email(request, user) - return redirect('verify') + return redirect("verify") else: messages.error( request, 'Your account is not active. ' @@ -189,23 +198,24 @@ def login(request): messages.error(request, 'Your username or password does not match an account.') return redirect('login') else: - return render(request, "accounts/login.html", {'envi': envi}) + return render(request, "accounts/login.html", {"envi": envi}) -@login_required(login_url='login') +@login_required(login_url="login") def logout(request): - if request.method == 'POST': + if request.method == "POST": auth.logout(request) - return redirect('login') + return redirect("login") def landing(request): - return redirect('login') + return redirect("login") -@login_required(login_url='login') +@login_required(login_url="login") def select_account(request): - return render(request, 'accounts/select-account.html') + envi = dev_prod_or_local(request.get_host()) + return render(request, "accounts/select-account.html", {"envi": envi}) class CustomSocialSignupView(SignupView): @@ -213,7 +223,8 @@ class CustomSocialSignupView(SignupView): def dispatch(self, request, *args, **kwargs): if not request.user.is_authenticated: messages.error( - request, "You have already created your account using " + request, + "You have already created your account using " "username and password. Please use those to login instead. " "You can still connect your account with " "Google once you login." @@ -231,8 +242,8 @@ def dispatch(self, request, *args, **kwargs): has_password = request.user.has_usable_password() if social_account and has_password: social_account.delete() - messages.info(request, 'The social account has been disconnected.') - return redirect('link-account') + messages.info(request, "The social account has been disconnected.") + return redirect("link-account") else: messages.error(request, 'Please set password first to unlink an account.') return redirect('link-account') @@ -240,7 +251,7 @@ def dispatch(self, request, *args, **kwargs): class CustomPasswordResetView(PasswordResetView): - email_template_name = 'password_reset' + email_template_name = "password_reset" from_email = settings.EMAIL_HOST_USER template_name = "accounts/password-reset.html" form_class = CustomPasswordResetForm @@ -250,72 +261,72 @@ def dispatch(self, *args, **kwargs): return super().dispatch(*args, **kwargs) -@login_required(login_url='login') +@login_required(login_url="login") def dashboard(request): user = request.user profile = user.user_profile researcher = is_user_researcher(user) affiliation = user.user_affiliations.prefetch_related( - 'communities', 'institutions', 'communities__admins', 'communities__editors', - 'communities__viewers', 'institutions__admins', 'institutions__editors', - 'institutions__viewers' + 'communities', 'institutions', 'service_providers', 'communities__admins', + 'communities__editors', 'communities__viewers', 'institutions__admins', + 'institutions__editors', 'institutions__viewers' ).all().first() user_communities = affiliation.communities.all() user_institutions = affiliation.institutions.all() + user_service_providers = affiliation.service_providers.all() + unsubscribed_institute = Institution.objects.filter( + institution_creator=user + ).first() - if request.method == 'POST': + if request.method == "POST": profile.onboarding_on = False profile.save() context = { - 'profile': profile, - 'user_communities': user_communities, - 'user_institutions': user_institutions, - 'researcher': researcher, + "profile": profile, + "user_communities": user_communities, + "user_institutions": user_institutions, + "user_service_providers": user_service_providers, + "researcher": researcher, + "unsubscribed_institute": unsubscribed_institute, } return render(request, "accounts/dashboard.html", context) -@login_required(login_url='login') +@login_required(login_url="login") def onboarding_on(request): request.user.user_profile.onboarding_on = True request.user.user_profile.save() - return redirect('dashboard') + return redirect("dashboard") -@login_required(login_url='login') +@login_required(login_url="login") def create_profile(request): - if request.method == 'POST': + if request.method == "POST": user_form = UserCreateProfileForm(request.POST, instance=request.user) profile_form = ProfileCreationForm(request.POST, instance=request.user.user_profile) if user_form.is_valid() and profile_form.is_valid(): user_form.save() profile_form.save() - # update user mailing list with name - add_to_active_users_mailing_list( - request, - request.user.email, - request.user.get_full_name() - ) - return redirect('select-account') + return redirect("select-account") else: user_form = UserCreateProfileForm(instance=request.user) profile_form = ProfileCreationForm(instance=request.user.user_profile) context = { - 'user_form': user_form, - 'profile_form': profile_form, + "user_form": user_form, + "profile_form": profile_form, } - return render(request, 'accounts/create-profile.html', context) + return render(request, "accounts/create-profile.html", context) -@login_required(login_url='login') +@login_required(login_url="login") def update_profile(request): - profile = Profile.objects.select_related('user').get(user=request.user) + profile = Profile.objects.select_related("user").get(user=request.user) - if request.method == 'POST': + if request.method == "POST": old_email = request.user.email user_form = UserUpdateForm(request.POST, instance=request.user) profile_form = ProfileUpdateForm( @@ -323,7 +334,7 @@ def update_profile(request): ) new_email = user_form.data['email'] - if new_email != old_email and new_email != '' and user_form.is_valid(): + if new_email != old_email and new_email != "" and user_form.is_valid(): user_form.instance.email = old_email profile_form.save() user_form.save() @@ -343,8 +354,8 @@ def update_profile(request): elif user_form.is_valid() and profile_form.is_valid(): user_form.save() profile_form.save() - messages.add_message(request, messages.SUCCESS, 'Profile updated!') - return redirect('update-profile') + messages.add_message(request, messages.SUCCESS, "Profile updated!") + return redirect("update-profile") else: user_form = UserUpdateForm(instance=request.user) profile_form = ProfileUpdateForm(instance=request.user.user_profile) @@ -353,11 +364,11 @@ def update_profile(request): return render(request, 'accounts/update-profile.html', context) -@login_required(login_url='login') +@login_required(login_url="login") def confirm_email(request, uidb64, token): try: - decoded_token = urlsafe_base64_decode(token).decode('utf-8') - new_token, new_email, user_id_str = decoded_token.split(' ') + decoded_token = urlsafe_base64_decode(token).decode("utf-8") + new_token, new_email, user_id_str = decoded_token.split(" ") except ValueError: messages.add_message(request, messages.ERROR, 'Invalid Verification token.') return redirect('login') @@ -366,8 +377,8 @@ def confirm_email(request, uidb64, token): user_id = user_id_str.strip() if not User.objects.filter(pk=user_id).exists(): - messages.add_message(request, messages.ERROR, 'User not found.') - return redirect('login') + messages.add_message(request, messages.ERROR, "User not found.") + return redirect("login") user = User.objects.get(pk=user_id) user.email = new_email user.save() @@ -375,9 +386,9 @@ def confirm_email(request, uidb64, token): return redirect('dashboard') -@login_required(login_url='login') +@login_required(login_url="login") def change_password(request): - profile = Profile.objects.select_related('user').get(user=request.user) + profile = Profile.objects.select_related("user").get(user=request.user) has_usable_password = request.user.has_usable_password() if not has_usable_password: @@ -385,7 +396,7 @@ def change_password(request): else: form = PasswordChangeForm(request.user, request.POST or None) - if request.method == 'POST': + if request.method == "POST": if form.is_valid(): form.save() update_session_auth_hash(request, form.user) @@ -397,8 +408,9 @@ def change_password(request): return render(request, 'accounts/change-password.html', {'profile': profile, 'form': form}) -@login_required(login_url='login') +@login_required(login_url="login") def deactivate_user(request): + profile = Profile.objects.select_related("user").get(user=request.user) user = request.user user_role = determine_user_role(user=user) profile = Profile.objects.select_related('user').get(user=user) @@ -435,12 +447,12 @@ def deactivate_user(request): }) -@login_required(login_url='login') +@login_required(login_url="login") def manage_organizations(request): - profile = Profile.objects.select_related('user').get(user=request.user) + profile = Profile.objects.select_related("user").get(user=request.user) affiliations = UserAffiliation.objects.prefetch_related( - 'communities', 'institutions', 'communities__community_creator', - 'institutions__institution_creator' + 'communities', 'institutions', 'service_providers', 'communities__community_creator', + 'institutions__institution_creator', 'service_providers__account_creator', ).get(user=request.user) researcher = Researcher.objects.none() users_name = get_users_name(request.user) @@ -482,7 +494,7 @@ def leave_account(request, account_type, account_id): return redirect('manage-orgs') -@login_required(login_url='login') +@login_required(login_url="login") def link_account(request): has_social_account = SocialAccount.objects.filter(user=request.user).exists() provider = None @@ -498,43 +510,56 @@ def link_account(request): ) -@login_required(login_url='login') +@login_required(login_url="login") def member_invitations(request): - profile = Profile.objects.select_related('user').get(user=request.user) + profile = Profile.objects.select_related("user").get(user=request.user) member_invites = InviteMember.objects.filter(receiver=request.user) + for invite in member_invites: + if invite.institution and invite.role.lower() in ( + "editor", + "administrator", + "admin", + ): + try: + subscription = Subscription.objects.get( + institution=invite.institution_id + ) + invite.has_zero_user_count = subscription.users_count == 0 + except Subscription.DoesNotExist: + subscription = None - if request.method == 'POST': - invite_id = request.POST.get('invite_id') + if request.method == "POST": + invite_id = request.POST.get("invite_id") accept_member_invite(request, invite_id) - return redirect('member-invitations') + return redirect("member-invitations") context = { - 'profile': profile, - 'member_invites': member_invites, + "profile": profile, + "member_invites": member_invites, } - return render(request, 'accounts/member-invitations.html', context) + return render(request, "accounts/member-invitations.html", context) -@login_required(login_url='login') +@login_required(login_url="login") def delete_member_invitation(request, pk): - profile = Profile.objects.select_related('user').get(user=request.user) + profile = Profile.objects.select_related("user").get(user=request.user) member_invites = InviteMember.objects.filter(receiver=request.user) target_member_invite = InviteMember.objects.get(id=pk) target_member_invite.delete() context = { - 'profile': profile, - 'member_invites': member_invites, + "profile": profile, + "member_invites": member_invites, } - return render(request, 'accounts/member-invitations.html', context) + return render(request, "accounts/member-invitations.html", context) -@login_required(login_url='login') +@login_required(login_url="login") def invite_user(request): # use internally referred path, otherwise use the default path - default_path = 'invite' - referred_path = request.headers.get('Referer', default_path) + default_path = "invite" + referred_path = request.headers.get("Referer", default_path) selected_path = urllib.parse.urlparse(referred_path).path invite_form = SignUpInvitationForm(request.POST or None) @@ -561,75 +586,96 @@ def invite_user(request): # when validation fails and selected_path is not the default # redirect to selected path - if selected_path.strip('/') != default_path: + if selected_path.strip("/") != default_path: return redirect(selected_path) - return render(request, 'accounts/invite.html', {'invite_form': invite_form}) + return render(request, 'accounts/invite.html', + {'invite_form': invite_form}) def registry(request, filtertype=None): default_items_per_page = 20 try: - c = Community.approved.select_related('community_creator').prefetch_related( + c = Community.objects.select_related('community_creator').prefetch_related( 'admins', 'editors', 'viewers' ).all().order_by('community_name') - i = Institution.approved.select_related('institution_creator').prefetch_related( + i = Institution.objects.select_related('institution_creator').prefetch_related( 'admins', 'editors', 'viewers' ).all().order_by('institution_name') r = Researcher.objects.select_related('user').all().order_by('user__username') + sp = (ServiceProvider.objects.select_related("account_creator").all().order_by("name")) - if ('q' in request.GET) and (filtertype is not None): - q = request.GET.get('q') - return redirect('/registry/?q=' + q) + if ("q" in request.GET) and (filtertype is not None): + q = request.GET.get("q") + return redirect("/registry/?q=" + q) - elif ('q' in request.GET) and (filtertype is None): - q = request.GET.get('q') + elif ("q" in request.GET) and (filtertype is None): + q = request.GET.get("q") q = unidecode(q) # removes accents from search query # Filter's accounts by the search query, # showing results that match with or without accents c = c.filter(community_name__unaccent__icontains=q) i = i.filter(institution_name__unaccent__icontains=q) + sp = sp.filter(name__unaccent__icontains=q) r = r.filter( Q(user__username__unaccent__icontains=q) | Q(user__first_name__unaccent__icontains=q) | Q(user__last_name__unaccent__icontains=q) ) - cards = return_registry_accounts(c, r, i) + cards = return_registry_accounts(c, r, i, sp) p = Paginator(cards, default_items_per_page) else: - if filtertype == 'communities': + if filtertype == "community-all": cards = c - elif filtertype == 'institutions': + elif filtertype == "community-members": + cards = c.filter(is_member=True) + elif filtertype == "institution-all": cards = i - elif filtertype == 'researchers': + elif filtertype == "institution-subscribed": + cards = i.filter(is_subscribed=True) + elif filtertype == "service-provider-all": + cards = sp + elif filtertype == "service-provider-certified": + cards = sp.filter(is_certified=True) + elif filtertype == "researcher-all": cards = r - elif filtertype == 'otc': + elif filtertype == "researcher-subscribed": + cards = r.filter(is_subscribed=True) + elif filtertype == 'engagement-notice': researchers_with_otc = r.filter(otc_researcher_url__isnull=False).distinct() institutions_with_otc = i.filter(otc_institution_url__isnull=False).distinct() + service_providers_with_otc = sp.filter( + otc_service_provider_url__isnull=False).distinct() cards = return_registry_accounts( - None, researchers_with_otc, institutions_with_otc + None, + researchers_with_otc, + institutions_with_otc, + service_providers_with_otc ) + elif filtertype == 'all': + return redirect('registry') else: - cards = return_registry_accounts(c, r, i) + cards = return_registry_accounts(c, r, i, sp) p = Paginator(cards, default_items_per_page) - page_num = request.GET.get('page', 1) + page_num = request.GET.get("page", 1) page = p.page(page_num) context = { - 'researchers': r, - 'communities': c, - 'institutions': i, - 'items': page, - 'filtertype': filtertype + "researchers": r, + "communities": c, + "institutions": i, + "service_providers": sp, + "items": page, + "filtertype": filtertype, } - return render(request, 'accounts/registry.html', context) + return render(request, "accounts/registry.html", context) except Exception: raise Http404() @@ -637,22 +683,34 @@ def registry(request, filtertype=None): def projects_board(request, filtertype=None): try: - approved_institutions = Institution.objects.filter(is_approved=True - ).values_list('id', flat=True) + institutions = Institution.objects.all() approved_communities = Community.objects.filter(is_approved=True ).values_list('id', flat=True) - projects = Project.objects.filter( - Q(project_privacy='Public'), - Q(project_creator_project__institution__in=approved_institutions) - | Q(project_creator_project__community__in=approved_communities) - | Q(project_creator_project__researcher__user__isnull=False) - ).select_related('project_creator').order_by('-date_modified') - - if ('q' in request.GET) and (filtertype is not None): - q = request.GET.get('q') - return redirect('/projects-board/?q=' + q) - elif ('q' in request.GET) and (filtertype is None): - q = request.GET.get('q') + + public_projects_filter = Q(project_privacy='Public') + institution_projects_filter = Q( + project_creator_project__institution__in=institutions + ) + community_projects_filter = Q( + project_creator_project__community__in=approved_communities + ) + researcher_projects_filter = Q( + project_creator_project__researcher__user__isnull=False, + project_creator_project__researcher__is_subscribed=True + ) + projects = ( + Project.objects.filter(public_projects_filter & ( + institution_projects_filter | community_projects_filter + | researcher_projects_filter + ) + ).select_related("project_creator").order_by("-date_modified") + ) + + if ("q" in request.GET) and (filtertype is not None): + q = request.GET.get("q") + return redirect("/projects-board/?q=" + q) + elif ("q" in request.GET) and (filtertype is None): + q = request.GET.get("q") q = unidecode(q) # removes accents from search query # Filter's accounts by the search query, @@ -664,7 +722,7 @@ def projects_board(request, filtertype=None): p = Paginator(results, 10) else: - if filtertype == 'labels': + if filtertype == "labels": results = projects.filter( Q(bc_labels__isnull=False) | Q(tk_labels__isnull=False) ).distinct() @@ -675,7 +733,7 @@ def projects_board(request, filtertype=None): p = Paginator(results, 10) - page_num = request.GET.get('page', 1) + page_num = request.GET.get("page", 1) page = p.page(page_num) context = {'projects': projects, 'items': page, 'filtertype': filtertype} @@ -686,7 +744,7 @@ def projects_board(request, filtertype=None): # Hub stats page def hub_counter(request): - return redirect('/admin/dashboard/') + return redirect("/admin/dashboard/") @force_maintenance_mode_off @@ -702,10 +760,10 @@ def newsletter_subscription(request): return redirect('newsletter-subscription') else: if validate_recaptcha(request): - first_name = request.POST['first_name'] - last_name = request.POST['last_name'] - name = str(first_name) + str(' ') + str(last_name) - email = request.POST['email'] + first_name = request.POST["first_name"] + last_name = request.POST["last_name"] + name = str(first_name) + str(" ") + str(last_name) + email = request.POST["email"] emailb64 = urlsafe_base64_encode(force_bytes(email)) variables = manage_mailing_list(request, first_name, emailb64) add_to_newsletter_mailing_list(str(email), str(name), str(variables)) @@ -720,16 +778,16 @@ def newsletter_subscription(request): else: messages.error(request, 'Invalid reCAPTCHA. Please try again.') - return render(request, 'accounts/newsletter-subscription.html') + return render(request, "accounts/newsletter-subscription.html") else: - return redirect('login') + return redirect("login") @force_maintenance_mode_off def newsletter_unsubscription(request, emailb64): environment = dev_prod_or_local(request.get_host()) - if environment == 'PROD' or 'localhost' in request.get_host(): + if environment == "PROD" or "localhost" in request.get_host(): try: email = force_str(urlsafe_base64_decode(emailb64)) response = get_newsletter_member_info(email) @@ -740,34 +798,34 @@ def newsletter_unsubscription(request, emailb64): subscribed = member_info["subscribed"] if subscribed is True: for item in variables: - if item == 'tech': + if item == "tech": tech = variables[item] - if item == 'news': + if item == "news": news = variables[item] - if item == 'events': + if item == "events": events = variables[item] - if item == 'notice': + if item == "notice": notice = variables[item] - if item == 'labels': + if item == "labels": labels = variables[item] - if item == 'first_name': + if item == "first_name": first_name = variables[item] context = { - 'email': email, - 'tech': tech, - 'news': news, - 'events': events, - 'notice': notice, - 'labels': labels, - 'subscribed': subscribed + "email": email, + "tech": tech, + "news": news, + "events": events, + "notice": notice, + "labels": labels, + "subscribed": subscribed, } else: - context = {'subscribed': subscribed} + context = {"subscribed": subscribed} - if request.method == 'POST': - if 'updatebtn' in request.POST: - if 'unsubscribe' in request.POST: + if request.method == "POST": + if "updatebtn" in request.POST: + if "unsubscribe" in request.POST: unsubscribe_from_mailing_list(str(email), str(name)) messages.add_message( request, messages.SUCCESS, 'You unsubscribed successfully!' @@ -790,52 +848,117 @@ def newsletter_unsubscription(request, emailb64): raise Http404() else: - return redirect('login') + return redirect("login") -@login_required(login_url='login') -def api_keys(request): - profile = Profile.objects.get(user=request.user) - - if request.method == 'POST': - if 'generatebtn' in request.POST: - api_key, key = APIKey.objects.create_key(name=request.user.username) - profile.api_key = key - profile.save() - messages.add_message(request, messages.SUCCESS, 'API Key generated!') - page_key = profile.api_key - return redirect('api-key') - - elif 'hidebtn' in request.POST: - return redirect('api-key') - - elif 'continueKeyDeleteBtn' in request.POST: - api_key = APIKey.objects.get(name=request.user.username) - api_key.delete() - profile.api_key = None - profile.save() - messages.add_message(request, messages.SUCCESS, 'API Key deleted!') - return redirect('api-key') - - elif 'copybtn' in request.POST: - messages.add_message(request, messages.SUCCESS, 'Copied!') - return redirect('api-key') - - elif 'showbtn' in request.POST: - page_key = profile.api_key - context = {'api_key': page_key, 'has_key': True} - request.session['keyvisible'] = True - return redirect('api-key') - - keyvisible = request.session.pop('keyvisible', False) - - if request.method == 'GET': - if profile.api_key is None: - context = {'has_key': False} - return render(request, 'accounts/apikey.html', context) - elif profile.api_key is not None and keyvisible is not False: - context = {'has_key': True, 'keyvisible': keyvisible, 'api_key': profile.api_key} - return render(request, 'accounts/apikey.html', context) +def subscription_inquiry(request): + form = SubscriptionForm(request.POST or None) + form.fields.pop('account_type', None) + non_ror_institutes = serializers.serialize( + "json", Institution.objects.filter(is_ror=False) + ) + communities = serializers.serialize("json", Community.approved.all()) + service_providers = serializers.serialize("json", ServiceProvider.certified.all()) + + non_ror_institutes = escape_single_quotes(non_ror_institutes) + communities = escape_single_quotes(communities) + service_providers = escape_single_quotes(service_providers) + + if request.method == "POST": + if validate_recaptcha(request) and form.is_valid(): + inquiry_type_key = form.cleaned_data["inquiry_type"] + inquiry_type_display = dict(form.fields["inquiry_type"].choices + ).get(inquiry_type_key, "") + form.cleaned_data["inquiry_type"] = inquiry_type_display + + first_name = form.cleaned_data["first_name"] + last_name = form.cleaned_data["last_name"] + email = form.cleaned_data["email"] + organization = form.cleaned_data["organization_name"] + + if not last_name: + form.cleaned_data["last_name"] = first_name + account_exist = User.objects.filter(email=email).first() + institution = Institution.objects.filter(institution_name=organization).first() + try: + response = institute_account_subscription( + request, institution, account_exist, form, non_ror_institutes, + ) + return response + except Exception: + messages.add_message( + request, messages.ERROR, + ( + "An unexpected error has occurred. Please " + "try contacting the Local Contexts HUB." + ), + ) + return redirect("dashboard") + return render( + request, "accounts/subscription-inquiry.html", + { + "form": form, + "non_ror_institutes": non_ror_institutes, + "communities": communities, + "service_providers": service_providers, + }, + ) + + +@login_required(login_url="login") +def subscription(request, pk, account_type, related=None): + if dev_prod_or_local(request.get_host()) == "SANDBOX": + return redirect("dashboard") + + renew = False + + if account_type == 'institution' and ( + request.user in get_institution(pk).get_admins() + or + request.user == get_institution(pk).institution_creator + ): + institution = get_institution(pk) + member_role = check_member_role(request.user, institution) + try: + subscription = Subscription.objects.get(institution=institution) + except Subscription.DoesNotExist: + subscription = None + if subscription is not None: + if subscription.end_date and subscription.end_date < timezone.now(): + renew = True + context = { + "institution": institution, + "subscription": subscription, + "start_date": subscription.start_date.strftime('%d %B %Y') + if subscription and subscription.start_date is not None + else None, + "end_date": subscription.end_date.strftime('%d %B %Y') + if subscription and subscription.end_date is not None + else None, + "renew": renew, + "member_role": member_role, + } + if account_type == 'researcher': + researcher = Researcher.objects.get(id=pk) + if researcher.is_subscribed: + subscription = Subscription.objects.filter(researcher=researcher).first() else: - context = {'api_key': '**********************************', 'has_key': True} - return render(request, 'accounts/apikey.html', context) + subscription = None + if subscription is not None: + if subscription.end_date and subscription.end_date < timezone.now(): + renew = True + context = { + "researcher": researcher, + "subscription": subscription, + "start_date": subscription.start_date.strftime('%d %B %Y') + if subscription and subscription.start_date is not None + else None, + "end_date": subscription.end_date.strftime('%d %B %Y') + if subscription and subscription.end_date is not None + else None, + "renew": renew + } + return render( + request, 'account_settings_pages/_subscription.html', + context + ) diff --git a/api/base/serializers.py b/api/base/serializers.py index ea07479e6..5eb283008 100644 --- a/api/base/serializers.py +++ b/api/base/serializers.py @@ -6,6 +6,7 @@ from projects.models import Project, ProjectCreator from institutions.models import Institution from researchers.models import Researcher +from accounts.models import Subscription from django.contrib.auth.models import User class InstitutionSerializer(serializers.ModelSerializer): @@ -131,4 +132,27 @@ def get_sub_projects(self, obj): class GetUserSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ('id', 'username', 'email', 'first_name', 'last_name') \ No newline at end of file + fields = ('id', 'username', 'email', 'first_name', 'last_name') + +class GetSubscriptionSerializer(serializers.ModelSerializer): + account_id = serializers.SerializerMethodField() + + def get_account_id(self, obj): + institution = obj.institution + researcher = obj.researcher + community = obj.community + + if institution: + account_id = str(institution.id) + '_i' + elif researcher: + account_id = str(researcher.id) + '_r' + elif community: + account_id = str(community.id) + '_c' + else: + account_id = None + + return account_id + + class Meta: + model = Subscription + fields = ['account_id', 'users_count', 'api_key_count', 'project_count', 'notification_count', 'date_last_updated'] diff --git a/api/base/views.py b/api/base/views.py index e8014ed59..f2a0e3a01 100644 --- a/api/base/views.py +++ b/api/base/views.py @@ -1,4 +1,6 @@ from django.db.models import Q +from django.http import Http404 +from django.conf import settings from rest_framework.decorators import api_view from rest_framework.response import Response from rest_framework.reverse import reverse @@ -7,12 +9,15 @@ from rest_framework import status from rest_framework.viewsets import ViewSet -from .serializers import * -from projects.models import Project +from .serializers import ( + ProjectOverviewSerializer, ProjectSerializer, ProjectNoNoticeSerializer, ProjectDateModified +) +from django.contrib.auth.models import User +from projects.models import Project, ProjectCreator from helpers.models import Notice -from projects.models import ProjectCreator -from django.http import Http404 -from django.conf import settings +from institutions.models import Institution +from researchers.models import Researcher + @api_view(['GET']) def apiOverview(request, format=None): @@ -53,7 +58,7 @@ class ProjectList(generics.ListAPIView): # '=' exact matches # '$' regex search -#TODO: Add option to pass Providers ID using Project Detail search + class ProjectDetail(generics.RetrieveAPIView): lookup_field = 'unique_id' queryset = Project.objects.exclude(project_privacy='Private') diff --git a/api/forms.py b/api/forms.py new file mode 100644 index 000000000..0d90b33bd --- /dev/null +++ b/api/forms.py @@ -0,0 +1,18 @@ +from django import forms +from .models import AccountAPIKey +from django.core.exceptions import ValidationError + +class APIKeyGeneratorForm(forms.ModelForm): + + class Meta: + model = AccountAPIKey + fields = ['name'] + widgets ={ + 'name': forms.TextInput(attrs={'required': True, "placeholder": "Enter a name for your API key", 'class': 'w-100'}) + } + + def clean_name(self): + key_name = self.cleaned_data.get("name") + if not key_name: + raise ValidationError("Please enter an API Key name.") + return key_name \ No newline at end of file diff --git a/api/migrations/0001_initial.py b/api/migrations/0001_initial.py new file mode 100644 index 000000000..f320d7499 --- /dev/null +++ b/api/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# Generated by Django 3.2 on 2024-04-08 16:50 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('communities', '0050_merge_0048_auto_20240123_0046_0049_alter_boundary_id'), + ('institutions', '0032_institution_is_subscribed'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='AccountAPIKey', + fields=[ + ('id', models.CharField(editable=False, max_length=150, primary_key=True, serialize=False, unique=True)), + ('prefix', models.CharField(editable=False, max_length=8, unique=True)), + ('hashed_key', models.CharField(editable=False, max_length=150)), + ('created', models.DateTimeField(auto_now_add=True, db_index=True)), + ('name', models.CharField(default=None, help_text='A free-form name for the API key. Need not be unique. 50 characters max.', max_length=50)), + ('revoked', models.BooleanField(blank=True, default=False, help_text='If the API key is revoked, clients cannot use it anymore. (This cannot be undone.)')), + ('expiry_date', models.DateTimeField(blank=True, help_text='Once API key expires, clients cannot use it anymore.', null=True, verbose_name='Expires')), + ('communities', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='community_api_keys', to='communities.community')), + ('developers', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_api_keys', to=settings.AUTH_USER_MODEL)), + ('institutions', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='institution_api_keys', to='institutions.institution')), + ], + options={ + 'verbose_name': 'Account API Key', + 'verbose_name_plural': 'Account API Keys', + 'ordering': ('-created',), + 'abstract': False, + }, + ), + ] diff --git a/api/migrations/0002_auto_20240409_1533.py b/api/migrations/0002_auto_20240409_1533.py new file mode 100644 index 000000000..26c0c5559 --- /dev/null +++ b/api/migrations/0002_auto_20240409_1533.py @@ -0,0 +1,56 @@ +# Generated by Django 3.2 on 2024-04-09 19:33 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('communities', '0050_merge_0048_auto_20240123_0046_0049_alter_boundary_id'), + ('institutions', '0032_institution_is_subscribed'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('researchers', '0036_alter_researcher_id'), + ('api', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='accountapikey', + name='communities', + ), + migrations.RemoveField( + model_name='accountapikey', + name='developers', + ), + migrations.RemoveField( + model_name='accountapikey', + name='institutions', + ), + migrations.AddField( + model_name='accountapikey', + name='community', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='community_api_keys', to='communities.community'), + ), + migrations.AddField( + model_name='accountapikey', + name='developer', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_api_keys', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='accountapikey', + name='encrypted_key', + field=models.CharField(blank=True, max_length=255, null=True, unique=True), + ), + migrations.AddField( + model_name='accountapikey', + name='institution', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='institution_api_keys', to='institutions.institution'), + ), + migrations.AddField( + model_name='accountapikey', + name='researcher', + field=models.ForeignKey(default=None, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='researcher_api_keys', to='researchers.researcher'), + ), + ] diff --git a/api/migrations/0003_auto_20240409_1537.py b/api/migrations/0003_auto_20240409_1537.py new file mode 100644 index 000000000..a163ae8da --- /dev/null +++ b/api/migrations/0003_auto_20240409_1537.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2 on 2024-04-09 19:37 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('researchers', '0036_alter_researcher_id'), + ('communities', '0050_merge_0048_auto_20240123_0046_0049_alter_boundary_id'), + ('institutions', '0032_institution_is_subscribed'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('api', '0002_auto_20240409_1533'), + ] + + operations = [ + migrations.AlterField( + model_name='accountapikey', + name='community', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='community_api_keys', to='communities.community'), + ), + migrations.AlterField( + model_name='accountapikey', + name='developer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_api_keys', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='accountapikey', + name='institution', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='institution_api_keys', to='institutions.institution'), + ), + migrations.AlterField( + model_name='accountapikey', + name='researcher', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='researcher_api_keys', to='researchers.researcher'), + ), + ] diff --git a/api/migrations/0004_auto_20240409_1555.py b/api/migrations/0004_auto_20240409_1555.py new file mode 100644 index 000000000..a9901dd03 --- /dev/null +++ b/api/migrations/0004_auto_20240409_1555.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2 on 2024-04-09 19:55 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('researchers', '0036_alter_researcher_id'), + ('institutions', '0032_institution_is_subscribed'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('communities', '0050_merge_0048_auto_20240123_0046_0049_alter_boundary_id'), + ('api', '0003_auto_20240409_1537'), + ] + + operations = [ + migrations.AlterField( + model_name='accountapikey', + name='community', + field=models.ForeignKey(blank=True, default=None, on_delete=django.db.models.deletion.CASCADE, related_name='community_api_keys', to='communities.community'), + ), + migrations.AlterField( + model_name='accountapikey', + name='developer', + field=models.ForeignKey(blank=True, default=None, on_delete=django.db.models.deletion.CASCADE, related_name='user_api_keys', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='accountapikey', + name='institution', + field=models.ForeignKey(blank=True, default=None, on_delete=django.db.models.deletion.CASCADE, related_name='institution_api_keys', to='institutions.institution'), + ), + migrations.AlterField( + model_name='accountapikey', + name='researcher', + field=models.ForeignKey(blank=True, default=None, on_delete=django.db.models.deletion.CASCADE, related_name='researcher_api_keys', to='researchers.researcher'), + ), + ] diff --git a/api/migrations/0005_auto_20240409_1610.py b/api/migrations/0005_auto_20240409_1610.py new file mode 100644 index 000000000..076b40465 --- /dev/null +++ b/api/migrations/0005_auto_20240409_1610.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2 on 2024-04-09 20:10 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('institutions', '0032_institution_is_subscribed'), + ('communities', '0050_merge_0048_auto_20240123_0046_0049_alter_boundary_id'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('researchers', '0036_alter_researcher_id'), + ('api', '0004_auto_20240409_1555'), + ] + + operations = [ + migrations.AlterField( + model_name='accountapikey', + name='community', + field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='community_api_keys', to='communities.community'), + ), + migrations.AlterField( + model_name='accountapikey', + name='developer', + field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_api_keys', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='accountapikey', + name='institution', + field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='institution_api_keys', to='institutions.institution'), + ), + migrations.AlterField( + model_name='accountapikey', + name='researcher', + field=models.ForeignKey(blank=True, on_delete=django.db.models.deletion.CASCADE, related_name='researcher_api_keys', to='researchers.researcher'), + ), + ] diff --git a/api/migrations/0006_auto_20240409_1612.py b/api/migrations/0006_auto_20240409_1612.py new file mode 100644 index 000000000..0cca40ba1 --- /dev/null +++ b/api/migrations/0006_auto_20240409_1612.py @@ -0,0 +1,39 @@ +# Generated by Django 3.2 on 2024-04-09 20:12 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('communities', '0050_merge_0048_auto_20240123_0046_0049_alter_boundary_id'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('institutions', '0032_institution_is_subscribed'), + ('researchers', '0036_alter_researcher_id'), + ('api', '0005_auto_20240409_1610'), + ] + + operations = [ + migrations.AlterField( + model_name='accountapikey', + name='community', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='community_api_keys', to='communities.community'), + ), + migrations.AlterField( + model_name='accountapikey', + name='developer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='user_api_keys', to=settings.AUTH_USER_MODEL), + ), + migrations.AlterField( + model_name='accountapikey', + name='institution', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='institution_api_keys', to='institutions.institution'), + ), + migrations.AlterField( + model_name='accountapikey', + name='researcher', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='researcher_api_keys', to='researchers.researcher'), + ), + ] diff --git a/api/migrations/0007_alter_accountapikey_encrypted_key.py b/api/migrations/0007_alter_accountapikey_encrypted_key.py new file mode 100644 index 000000000..c1d3be25c --- /dev/null +++ b/api/migrations/0007_alter_accountapikey_encrypted_key.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2024-04-10 15:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0006_auto_20240409_1612'), + ] + + operations = [ + migrations.AlterField( + model_name='accountapikey', + name='encrypted_key', + field=models.CharField(editable=False, max_length=255, unique=True), + ), + ] diff --git a/api/migrations/0008_alter_accountapikey_encrypted_key.py b/api/migrations/0008_alter_accountapikey_encrypted_key.py new file mode 100644 index 000000000..ba2e578a1 --- /dev/null +++ b/api/migrations/0008_alter_accountapikey_encrypted_key.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2024-04-10 19:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0007_alter_accountapikey_encrypted_key'), + ] + + operations = [ + migrations.AlterField( + model_name='accountapikey', + name='encrypted_key', + field=models.CharField(blank=True, max_length=255, null=True, unique=True), + ), + ] diff --git a/api/migrations/0009_accountapikey_service_provider.py b/api/migrations/0009_accountapikey_service_provider.py new file mode 100644 index 000000000..031075dac --- /dev/null +++ b/api/migrations/0009_accountapikey_service_provider.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.6 on 2024-07-01 16:09 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('api', '0008_alter_accountapikey_encrypted_key'), + ('serviceproviders', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='accountapikey', + name='service_provider', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='service_provider_api_keys', to='serviceproviders.serviceprovider'), + ), + ] diff --git a/api/models.py b/api/models.py index 71a836239..76e553d4e 100644 --- a/api/models.py +++ b/api/models.py @@ -1,3 +1,50 @@ from django.db import models +from rest_framework_api_key.models import AbstractAPIKey -# Create your models here. +from django.contrib.auth.models import User +from communities.models import Community +from institutions.models import Institution +from researchers.models import Researcher +from serviceproviders.models import ServiceProvider + +class AccountAPIKey(AbstractAPIKey): + institution = models.ForeignKey( + Institution, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="institution_api_keys" + ) + community = models.ForeignKey( + Community, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="community_api_keys" + ) + researcher = models.ForeignKey( + Researcher, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="researcher_api_keys" + ) + developer = models.ForeignKey( + User, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="user_api_keys" + ) + service_provider = models.ForeignKey( + ServiceProvider, + on_delete=models.CASCADE, + blank=True, + null=True, + related_name="service_provider_api_keys" + ) + encrypted_key = models.CharField(unique=True, max_length=255, null=True, blank=True) + + class Meta(AbstractAPIKey.Meta): + verbose_name = "Account API Key" + verbose_name_plural = "Account API Keys" \ No newline at end of file diff --git a/api/versioned/v2/serializers.py b/api/versioned/v2/serializers.py index 36217c1b7..f02fde18c 100644 --- a/api/versioned/v2/serializers.py +++ b/api/versioned/v2/serializers.py @@ -1,2 +1,324 @@ +from django.urls import reverse +from rest_framework import serializers from api.base import serializers as base_serializers -from api.base.serializers import * \ No newline at end of file + +from drf_spectacular.utils import extend_schema_field, inline_serializer +from drf_spectacular.types import OpenApiTypes + +from projects.models import ProjectContributors, ProjectPerson, Project +from communities.models import Community + +from api.views import HELPTEXT_CHOICES + + +class InstitutionSerializer(base_serializers.InstitutionSerializer): + name = serializers.CharField( + source='institution_name', help_text=HELPTEXT_CHOICES['account_name'] + ) + profile_url = serializers.SerializerMethodField(help_text=HELPTEXT_CHOICES['profile_url']) + + class Meta(base_serializers.InstitutionSerializer.Meta): + fields = ('id', 'name', 'profile_url', 'ror_id') + extra_kwargs = { + 'id': {'help_text': HELPTEXT_CHOICES['id']}, + 'ror_id': {'help_text': HELPTEXT_CHOICES['ror_id']} + } + + @extend_schema_field(OpenApiTypes.URI) + def get_profile_url(self, obj): + request = self.context.get('request') + profile_url = reverse('public-institution', kwargs={'pk': obj.id}) + return request.build_absolute_uri(profile_url) + + +class ResearcherSerializer(base_serializers.ResearcherSerializer): + name = serializers.SerializerMethodField(help_text=HELPTEXT_CHOICES['account_name']) + profile_url = serializers.SerializerMethodField(help_text=HELPTEXT_CHOICES['profile_url']) + + class Meta(base_serializers.ResearcherSerializer.Meta): + fields = ('id', 'name', 'profile_url', 'orcid') + extra_kwargs = { + 'id': {'help_text': HELPTEXT_CHOICES['id']}, + 'orcid': {'help_text': HELPTEXT_CHOICES['ror_id']} + } + + @extend_schema_field(OpenApiTypes.STR) + def get_name(self, obj): + if obj.user.first_name and obj.user.last_name: + full_name = str(obj.user.first_name) + ' ' + str(obj.user.last_name) + else: + full_name = str(obj.user.username) + return full_name + + @extend_schema_field(OpenApiTypes.URI) + def get_profile_url(self, obj): + request = self.context.get('request') + profile_url = reverse('public-researcher', kwargs={'pk': obj.user.id}) + return request.build_absolute_uri(profile_url) + + +class CommunitySerializer(serializers.ModelSerializer): + name = serializers.CharField( + source='community_name', help_text=HELPTEXT_CHOICES['account_name'] + ) + profile_url = serializers.SerializerMethodField(help_text=HELPTEXT_CHOICES['profile_url']) + + class Meta: + model = Community + fields = ('id', 'name', 'profile_url') + extra_kwargs = {'id': {'help_text': HELPTEXT_CHOICES['id']}} + + @extend_schema_field(OpenApiTypes.URI) + def get_profile_url(self, obj): + request = self.context.get('request') + profile_url = reverse('public-community', kwargs={'pk': obj.id}) + return request.build_absolute_uri(profile_url) + + +class LabelTranslationSerializer(base_serializers.LabelTranslationSerializer): + class Meta(base_serializers.LabelTranslationSerializer.Meta): + extra_kwargs = { + 'translated_name': {'help_text': HELPTEXT_CHOICES['translated_name']}, + 'language_tag': {'help_text': HELPTEXT_CHOICES['language_tag']}, + 'language': {'help_text': HELPTEXT_CHOICES['language']}, + 'translated_text': {'help_text': HELPTEXT_CHOICES['translated_text']}, + } + + +class BCLabelSerializer(base_serializers.BCLabelSerializer): + community = CommunitySerializer(help_text=HELPTEXT_CHOICES['community']) + translations = LabelTranslationSerializer( + source="bclabel_translation", many=True, help_text=HELPTEXT_CHOICES['translations'] + ) + + class Meta(base_serializers.BCLabelSerializer.Meta): + extra_kwargs = { + 'unique_id': {'help_text': HELPTEXT_CHOICES['unique_id']}, + 'name': {'help_text': HELPTEXT_CHOICES['name']}, + 'label_type': {'help_text': HELPTEXT_CHOICES['label_type']}, + 'language_tag': {'help_text': HELPTEXT_CHOICES['language_tag']}, + 'language': {'help_text': HELPTEXT_CHOICES['language']}, + 'label_text': {'help_text': HELPTEXT_CHOICES['label_text']}, + 'img_url': {'help_text': HELPTEXT_CHOICES['img_url']}, + 'svg_url': {'help_text': HELPTEXT_CHOICES['svg_url']}, + 'audiofile': {'help_text': HELPTEXT_CHOICES['audiofile']}, + 'created': {'help_text': HELPTEXT_CHOICES['created']}, + 'updated': {'help_text': HELPTEXT_CHOICES['updated']} + } + + +class TKLabelSerializer(base_serializers.TKLabelSerializer): + community = CommunitySerializer(help_text=HELPTEXT_CHOICES['community']) + translations = LabelTranslationSerializer( + source="tklabel_translation", many=True, help_text=HELPTEXT_CHOICES['translations'] + ) + + class Meta(base_serializers.TKLabelSerializer.Meta): + extra_kwargs = { + 'unique_id': {'help_text': HELPTEXT_CHOICES['unique_id']}, + 'name': {'help_text': HELPTEXT_CHOICES['name']}, + 'label_type': {'help_text': HELPTEXT_CHOICES['label_type']}, + 'language_tag': {'help_text': HELPTEXT_CHOICES['language_tag']}, + 'language': {'help_text': HELPTEXT_CHOICES['language']}, + 'label_text': {'help_text': HELPTEXT_CHOICES['label_text']}, + 'img_url': {'help_text': HELPTEXT_CHOICES['img_url']}, + 'svg_url': {'help_text': HELPTEXT_CHOICES['svg_url']}, + 'audiofile': {'help_text': HELPTEXT_CHOICES['audiofile']}, + 'created': {'help_text': HELPTEXT_CHOICES['created']}, + 'updated': {'help_text': HELPTEXT_CHOICES['updated']} + } + + +class NoticeTranslationsSerializer(base_serializers.NoticeTranslationsSerializer): + class Meta(base_serializers.NoticeTranslationsSerializer.Meta): + extra_kwargs = { + 'translated_name': {'help_text': HELPTEXT_CHOICES['translated_name']}, + 'language_tag': {'help_text': HELPTEXT_CHOICES['language_tag']}, + 'language': {'help_text': HELPTEXT_CHOICES['language']}, + 'translated_text': {'help_text': HELPTEXT_CHOICES['translated_text']}, + } + + +class NoticeSerializer(base_serializers.NoticeSerializer): + translations = NoticeTranslationsSerializer( + source="notice_translations", many=True, help_text=HELPTEXT_CHOICES['translations'] + ) + + class Meta(base_serializers.NoticeSerializer.Meta): + extra_kwargs = { + 'notice_type': {'help_text': HELPTEXT_CHOICES['notice_type']}, + 'name': {'help_text': HELPTEXT_CHOICES['name']}, + 'img_url': {'help_text': HELPTEXT_CHOICES['img_url']}, + 'svg_url': {'help_text': HELPTEXT_CHOICES['svg_url']}, + 'default_text': {'help_text': HELPTEXT_CHOICES['default_text']}, + 'translations': {'help_text': HELPTEXT_CHOICES['translations']}, + 'created': {'help_text': HELPTEXT_CHOICES['created']}, + 'updated': {'help_text': HELPTEXT_CHOICES['updated']}, + } + + +class ProjectPersonSerializer(serializers.ModelSerializer): + class Meta: + model = ProjectPerson + fields = ('name', 'email') + extra_kwargs = { + 'name': {'help_text': HELPTEXT_CHOICES['person_name']}, + 'email': {'help_text': HELPTEXT_CHOICES['email']} + } + + +class ProjectCreatorSerializer(base_serializers.ProjectCreatorSerializer): + institution = InstitutionSerializer(help_text=HELPTEXT_CHOICES['institution']) + community = CommunitySerializer(help_text=HELPTEXT_CHOICES['community']) + researcher = ResearcherSerializer(help_text=HELPTEXT_CHOICES['researcher']) + + +class ProjectContributorSerializer(serializers.ModelSerializer): + institutions = InstitutionSerializer(many=True, help_text=HELPTEXT_CHOICES['institutions']) + communities = CommunitySerializer(many=True, help_text=HELPTEXT_CHOICES['communities']) + researchers = ResearcherSerializer(many=True, help_text=HELPTEXT_CHOICES['researchers']) + others = serializers.SerializerMethodField(help_text=HELPTEXT_CHOICES['others']) + + class Meta: + model = ProjectContributors + fields = ('institutions', 'communities', 'researchers', 'others') + + @extend_schema_field( + inline_serializer( + name = "Others", + fields= { + 'name': serializers.CharField(help_text=HELPTEXT_CHOICES['person_name']), + 'email': serializers.EmailField(help_text=HELPTEXT_CHOICES['email']) + }, + many=True + ), + OpenApiTypes.OBJECT + ) + def get_others(self, obj): + people = [ + people for people in ProjectPerson.objects.filter( + project=obj.project + ).values('name', 'email') + ] + return [person for person in people] + + +class ProjectOverviewSerializer(base_serializers.ProjectOverviewSerializer): + created_by = ProjectCreatorSerializer( + source="project_creator_project", many=True, help_text=HELPTEXT_CHOICES['created_by'] + ) + + class Meta(base_serializers.ProjectOverviewSerializer.Meta): + fields = base_serializers.ProjectOverviewSerializer.Meta.fields + ('created_by',) + extra_kwargs = { + 'unique_id': {'help_text': HELPTEXT_CHOICES['unique_id']}, + 'providers_id': {'help_text': HELPTEXT_CHOICES['providers_id']}, + 'title': {'help_text': HELPTEXT_CHOICES['title']}, + 'project_privacy': {'help_text': HELPTEXT_CHOICES['project_privacy']}, + 'date_added': {'help_text': HELPTEXT_CHOICES['date_added']}, + 'date_modified': {'help_text': HELPTEXT_CHOICES['date_modified']}, + } + + +class ProjectSerializer(base_serializers.ProjectSerializer): + external_ids = serializers.SerializerMethodField(help_text=HELPTEXT_CHOICES['external_ids']) + created_by = ProjectCreatorSerializer( + source="project_creator_project", many=True, help_text=HELPTEXT_CHOICES['created_by'] + ) + contributors = ProjectContributorSerializer( + source="project_contributors", help_text=HELPTEXT_CHOICES['contributors'] + ) + notice = NoticeSerializer( + source="project_notice", many=True, help_text=HELPTEXT_CHOICES['notice'] + ) + bc_labels = BCLabelSerializer(many=True, help_text=HELPTEXT_CHOICES['bc_labels']) + tk_labels = TKLabelSerializer(many=True, help_text=HELPTEXT_CHOICES['tk_labels']) + project_contact = serializers.SerializerMethodField( + help_text=HELPTEXT_CHOICES['project_contact'] + ) + related_projects = serializers.SerializerMethodField( + help_text=HELPTEXT_CHOICES['related_projects'] + ) + sub_projects = serializers.SerializerMethodField(help_text=HELPTEXT_CHOICES['sub_projects']) + + class Meta(base_serializers.ProjectSerializer.Meta): + fields = ( + 'unique_id', 'external_ids', 'source_project_uuid', 'project_page', 'title', + 'description', 'project_type', 'project_contact', 'urls', 'project_privacy', + 'date_added', 'date_modified', 'created_by', 'contributors', 'notice', 'bc_labels', + 'tk_labels', 'sub_projects', 'related_projects', 'project_boundary_geojson' + ) + extra_kwargs = { + 'unique_id': {'help_text': HELPTEXT_CHOICES['unique_id']}, + 'source_project_uuid': {'help_text': HELPTEXT_CHOICES['source_project_uuid']}, + 'project_page': {'help_text': HELPTEXT_CHOICES['project_page']}, + 'title': {'help_text': HELPTEXT_CHOICES['title']}, + 'description': {'help_text': HELPTEXT_CHOICES['description']}, + 'project_type': {'help_text': HELPTEXT_CHOICES['project_type']}, + 'urls': {'help_text': HELPTEXT_CHOICES['urls']}, + 'project_privacy': {'help_text': HELPTEXT_CHOICES['project_privacy']}, + 'date_added': {'help_text': HELPTEXT_CHOICES['date_added']}, + 'date_modified': {'help_text': HELPTEXT_CHOICES['date_modified']}, + 'project_boundary_geojson': { + 'help_text': HELPTEXT_CHOICES['project_boundary_geojson'] + } + } + + @extend_schema_field( + inline_serializer( + name = "ExternalIDs", + fields= { + 'providers_id': serializers.CharField( + help_text=HELPTEXT_CHOICES['providers_id'] + ), + 'publication_doi': serializers.CharField( + help_text=HELPTEXT_CHOICES['publication_doi'] + ), + 'project_data_guid': serializers.CharField( + help_text=HELPTEXT_CHOICES['project_data_guid'] + ) + } + ) + ) + def get_external_ids(self, obj): + external_ids = { + 'providers_id': obj.providers_id, + 'publication_doi': obj.publication_doi, + 'project_data_guid': obj.project_data_guid + } + return external_ids + + @extend_schema_field(OpenApiTypes.STR) + def get_related_projects(self, obj): + return [project.unique_id for project in obj.related_projects.all()] + + @extend_schema_field(OpenApiTypes.STR) + def get_sub_projects(self, obj): + return [p.unique_id for p in Project.objects.filter(source_project_uuid=obj.unique_id)] + + @extend_schema_field( + inline_serializer( + name = "ProjectContact", + fields= { + 'name': serializers.CharField(help_text=HELPTEXT_CHOICES['person_name']), + 'email': serializers.EmailField(help_text=HELPTEXT_CHOICES['email']) + } + ) + ) + def get_project_contact(self, obj): + return {'name': obj.project_contact, 'email': obj.project_contact_email} + + + +class ProjectDateModified(base_serializers.ProjectDateModified): + class Meta(base_serializers.ProjectDateModified.Meta): + extra_kwargs = { + 'unique_id': {'help_text': HELPTEXT_CHOICES['unique_id']}, + 'date_modified': {'help_text': HELPTEXT_CHOICES['date_modified']} + } + + +class OpenToCollaborationSerializer(serializers.Serializer): + institution = InstitutionSerializer(help_text=HELPTEXT_CHOICES['institution']) + researcher = ResearcherSerializer(help_text=HELPTEXT_CHOICES['researcher']) + notice = NoticeSerializer(help_text=HELPTEXT_CHOICES['notice']) diff --git a/api/versioned/v2/urls.py b/api/versioned/v2/urls.py index 908ac8176..dcdef01d2 100644 --- a/api/versioned/v2/urls.py +++ b/api/versioned/v2/urls.py @@ -1,15 +1,9 @@ from django.urls import path, re_path +from drf_spectacular.views import ( + SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView +) from .views import * -projects_by_user = ProjectsByIdViewSet.as_view({ - 'get':'projects_by_user' -}) -projects_by_institution = ProjectsByIdViewSet.as_view({ - 'get':'projects_by_institution' -}) -projects_by_researcher = ProjectsByIdViewSet.as_view({ - 'get':'projects_by_researcher' -}) multisearch = MultiProjectListDetail.as_view({ 'get':'multisearch' }) @@ -17,22 +11,44 @@ 'get':'multisearch_date' }) + urlpatterns = [ re_path(r'^$', APIOverview.as_view(), name="api-overview"), - path('notices/open_to_collaborate', OpenToCollaborateNotice.as_view(), name="api-open-to-collaborate"), + path( + 'notices/open_to_collaborate/', OpenToCollaborateNotice.as_view(), + name="api-open-to-collaborate" + ), path('get-user/', GetUserAPIView.as_view(), name='get-user'), path('projects/', ProjectList.as_view(), name="api-projects"), + path('subscription', SubscriptionAPI.as_view(), name="subscription"), path('projects//', ProjectDetail.as_view(), name="api-project-detail"), - # ADD path('projects//', ProjectDetail.as_view(), name="api-project-detail"), - # DELETE path('projects/external//', project_detail_providers, name="api-project-detail-providers"), - #ASHLEYTODO: change it so that the project detail (list view) can be used using either projectID or providersID. Two URLs that use one call. projects/external url would be removed - - path('projects/users//', projects_by_user, name="api-projects-user"), - path('projects/institutions//', projects_by_institution, name="api-projects-institution"), - path('projects/institutions//', projects_by_institution, name="api-projects-institution"), - path('projects/researchers//', projects_by_researcher, name="api-projects-researcher"), path('projects/multi//', multisearch, name="api-projects-multi"), - path('projects/date_modified//', date_modified, name="api-projects-date-modified") + path( + 'projects/multi/date_modified//', date_modified, + name="api-projects-date-modified" + ), + + # For Developers: Reminder to add the local server to the servers list below for testing. + path('schema/', SpectacularAPIView.as_view( + api_version='v2', + custom_settings= { + 'VERSION': '2.0.0', + 'SERVERS': [ + { + 'url': 'https://localcontextshub.org/api/v2/', + 'description': 'Live instance of the Local Contexts Hub.' + }, + { + 'url': 'https://sandbox.localcontextshub.org/api/v2/', + 'description': 'Sandbox/Testing site for the Local Contexts Hub API.' + } + ], + } + ), name='schema' + ), + path('docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc') + ] \ No newline at end of file diff --git a/api/versioned/v2/views.py b/api/versioned/v2/views.py index b814bf958..8212d7e1f 100644 --- a/api/versioned/v2/views.py +++ b/api/versioned/v2/views.py @@ -1,217 +1,630 @@ -from api.base.views import * -from api.base import views as base_views +import json +from itertools import chain +from datetime import datetime +from django.conf import settings +from django.contrib.auth.models import User +from django.shortcuts import get_object_or_404 +from django.db import IntegrityError +from django.db import transaction +from django.http import Http404 + +from rest_framework import generics, filters, status, serializers from rest_framework.views import APIView -from rest_framework.decorators import action -from . import serializers as v2_serializers from rest_framework.viewsets import ViewSet -from rest_framework_api_key.permissions import HasAPIKey -from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.status import HTTP_201_CREATED, HTTP_200_OK +from rest_framework.permissions import BasePermission from rest_framework.authentication import BaseAuthentication -from rest_framework.exceptions import AuthenticationFailed +from rest_framework.exceptions import AuthenticationFailed, PermissionDenied +from django_filters.rest_framework import ( + DjangoFilterBackend, FilterSet, NumberFilter, CharFilter, UUIDFilter +) +from drf_spectacular.extensions import OpenApiAuthenticationExtension +from drf_spectacular.utils import ( + extend_schema, inline_serializer, OpenApiResponse, OpenApiParameter +) +from drf_spectacular.types import OpenApiTypes -from django.conf import settings -from django.contrib.auth.models import User +from api.base import serializers as base_serializers +from api.views import HELPTEXT_CHOICES +from . import serializers as v2_serializers +from django.db.models import Q +from projects.models import Project +from institutions.models import Institution +from communities.models import Community +from researchers.models import Researcher +from accounts.models import Subscription +from api.models import AccountAPIKey + + +''' + USER API ERROR MESSAGES: + 1. For calls made without API Keys: `no_key` + 2. For invalid API Keys (deleted, revoked, expired): `invalid_key` + 3. For actions trying to be made by unsubscribed, uncertified, or unapproved accounts: + `incomplete_account` + 4. For actions trying to be made by an account that is not the owner/creator of the + project: `denied_project` + 5. For actions trying to be made by an account that does not have access to the call: + `denied_call` + 6. For invalid project IDs passed: `no_project` + 7. For calls that require UUID but wrong ID is passed: `invalid_id` +''' +ERROR_MESSAGE_CHOICES = { + 'no_key': "Authentication not provided.", + 'invalid_key': "Invalid API key.", + 'incomplete_account': "Your account must be Subscribed, Certified, or Confirmed to perform this action.", + 'denied_project': "You do not have permission to view this project.", + 'denied_call': "You do not have permission to view this call.", + 'no_project': "Project does not exist.", + 'invalid_id': "Invalid Project ID Provided.", + 'email_required': "Email parameter is required in the query parameters." +} -class ApiKeyAuthentication(BaseAuthentication): - VALID_USER_IDS = {10} # Replace with the actual list of valid user IDs +class APIKeyAuthentication(BaseAuthentication): + ''' + APIKeyAuthentication checks a valid API Key was passed. API Key from user will be the + encrypted key. If no API key provided, or an invalid key (expired/revoked) is provided, + raise AuthenticationFailed. + + self.request.user or request.user = The API key being used is considered the user in this + instance. The model being referenced is AccountAPIKey. + ''' def authenticate(self, request): api_key = request.headers.get('X-Api-Key') if not api_key: - return None + raise AuthenticationFailed(ERROR_MESSAGE_CHOICES['no_key']) try: - user = User.objects.get(user_profile__api_key=api_key) - except User.DoesNotExist: - raise AuthenticationFailed('Invalid API key') + account = AccountAPIKey.objects.get(encrypted_key=api_key) + except AccountAPIKey.DoesNotExist: + raise AuthenticationFailed(ERROR_MESSAGE_CHOICES['invalid_key']) + + return (account, None) + + +# OpenAPI Schema for LC API Key Authentication +class MyAuthenticationScheme(OpenApiAuthenticationExtension): + target_class = 'api.versioned.v2.views.APIKeyAuthentication' # full import path OR class ref + name = 'LCHubAPIKey' # name used in the schema + + def get_security_definition(self, auto_schema): + return { + 'type': 'apiKey', + 'in': 'header', + 'name': 'X-Api-Key', + } + + +class IsActive(BasePermission): + ''' + Checks for active status on subscriptions, service providers and members accounts. + If inactive, message appears. + TODO: Add is_certified and is_developer check for service providers and developers. + ''' + message = ERROR_MESSAGE_CHOICES['incomplete_account'] + + def has_permission(self, request, view): + account = request.user + + if account.researcher and account.researcher.is_subscribed: + return True + elif account.institution and account.institution.is_subscribed: + return True + elif account.community and account.community.is_approved: + return True + + return False + + +class IsSuperuser(BasePermission): + ''' + Checks that key is for a developer (user) account that has superuser access. + If not, permission blocked. + ''' + def has_permission(self, request, view): + try: + if request.user.developer.is_superuser: + return True + except: + return False + + +# FILTERS +class IsActiveCreatorFilter(filters.BaseFilterBackend): + ''' + Filter that only allows users to see public projects and their own created projects + if they are Active. Otherwise, they can only see projects that are not private. + ''' + def filter_queryset(self, request, queryset, view): + try: + account = request.user + if account.institution and account.institution.is_subscribed: + projects_list = list( + chain( + account.institution.institution_created_project.all().values_list( + "project__unique_id", flat=True + ), # institution created project ids + account.institution.contributing_institutions.all().values_list( + "project__unique_id", flat=True + ), # projects where institution is contributor + ) + ) + + elif account.researcher and account.researcher.is_subscribed: + projects_list = list( + chain( + account.researcher.researcher_created_project.all().values_list( + "project__unique_id", flat=True + ), # researcher created project ids + account.researcher.contributing_reserchers.all().values_list( + "project__unique_id", flat=True + ), # projects where researcher is contributor + ) + ) - # Check if the authenticated user is in the list of valid user IDs - if user.id not in self.VALID_USER_IDS: - raise AuthenticationFailed('Unauthorized user') + elif account.community and account.community.is_approved: + projects_list = list( + chain( + account.community.community_created_project.all().values_list( + "project__unique_id", flat=True + ), # community created project ids + account.community.contributing_communities.all().values_list( + "project__unique_id", flat=True + ), # projects where community is contributor + ) + ) - return (user, None) + projects = list(chain( + queryset.values_list("unique_id", flat=True), + projects_list + )) + project_ids = list(set(projects)) + return Project.objects.filter(unique_id__in=project_ids) + except: + return queryset + +class ProjectListFilterSet(FilterSet): + institution_id = NumberFilter( + field_name='project_creator_project__institution', lookup_expr='exact', + help_text=HELPTEXT_CHOICES['id'] + ) + researcher_id = NumberFilter( + field_name='project_creator_project__researcher', lookup_expr='exact', + help_text=HELPTEXT_CHOICES['id'] + ) + community_id = NumberFilter( + field_name='project_creator_project__community', lookup_expr='exact', + help_text=HELPTEXT_CHOICES['id'] + ) + user_id = NumberFilter( + field_name='project_creator', lookup_expr='exact', help_text=HELPTEXT_CHOICES['id'] + ) + title = CharFilter( + field_name='title', lookup_expr='iregex', help_text=HELPTEXT_CHOICES['title'] + ) + providers_id = CharFilter(help_text=HELPTEXT_CHOICES['providers_id']) + unique_id = UUIDFilter( + field_name='unique_id', lookup_expr='exact', help_text=HELPTEXT_CHOICES['unique_id'] + ) + + class Meta: + model = Project + fields = [ + 'institution_id', 'researcher_id', 'community_id', 'user_id', 'title', + 'providers_id', 'unique_id' + ] + + +# PUBLIC API CALLS class APIOverview(APIView): + @extend_schema( + request=None, + description="Get a list of all possible endpoints.", + operation_id= "api_overview", + responses={ + 200: inline_serializer( + name = "APIOverview", + fields = { + 'projects_list': serializers.CharField( + help_text="Displays the path of the Projects List endpoint." + ), + 'project_detail': serializers.CharField( + help_text="Displays the path of the Projects Detail endpoint." + ), + 'multi_project_detail': serializers.CharField( + help_text="Displays the path of the Projects Detail endpoint when " + "searching for multiple Projects." + ), + 'multi_project_date_modified': serializers.CharField( + help_text="Displays the path of the Project Date Modified endpoint when " + "searching for multiple Projects' modified date." + ), + 'open_to_collaborate_notice': serializers.CharField( + help_text="Displays the path of the Open to Collaborate Notice endpoint." + ), + 'api_documentation': serializers.URLField( + help_text="Displays the URL for the API Documentation." + ), + 'usage_guides': serializers.URLField( + help_text="Displays the URL to all Notices and Labels Usage Guides" + ) + } + ) + } + ) + def get(self, request, format=None): api_urls = { - 'server': reverse('api-overview', request=request, format=format), 'projects_list': '/projects/', 'project_detail': '/projects//', 'multi_project_detail':'/projects/multi/,/', - 'projects_by_user_id': '/projects/users//', - 'projects_by_institution_id': '/projects/institutions//', - 'projects_by_researcher_id': '/projects/researchers//', + 'multi_project_date_modified':'/projects/multi/date_modified/,/', 'open_to_collaborate_notice': '/notices/open_to_collaborate/', 'api_documentation': 'https://github.com/localcontexts/localcontextshub/wiki/API-Documentation', - 'usage_guides': 'https://localcontexts.org/support/downloadable-resources', + 'usage_guides': 'https://localcontexts.org/support/downloadable-resources' } return Response(api_urls) + class OpenToCollaborateNotice(APIView): - def get(self, request): - api_urls = { - 'notice_type': 'open_to_collaborate', - 'name': 'Open to Collaborate Notice', - 'default_text': 'Our institution is committed to the development of new modes of collaboration, engagement, and partnership with Indigenous peoples for the care and stewardship of past and future heritage collections.', - 'img_url': f'https://storage.googleapis.com/{settings.STORAGE_BUCKET}/labels/notices/ci-open-to-collaborate.png', - 'svg_url': f'https://storage.googleapis.com/{settings.STORAGE_BUCKET}/labels/notices/ci-open-to-collaborate.svg', - 'usage_guide_ci_notices': f'https://storage.googleapis.com/{settings.STORAGE_BUCKET}/guides/LC-Institution-Notices-Usage-Guide_2021-11-16.pdf', + authentication_classes = [APIKeyAuthentication,] + permission_classes = [IsActive,] + + @extend_schema( + request=v2_serializers.OpenToCollaborationSerializer, + description="Get information about the Open to Collaborate Notice.", + operation_id="open_to_collaborate", + responses={ + 200: v2_serializers.OpenToCollaborationSerializer, + 401: OpenApiResponse(description='Error: Permission Denied'), + 403: OpenApiResponse(description='Error: Forbidden'), } - return Response(api_urls) + ) + + def get(self, request): + if not request.user.community: + notice_json_data = open('./localcontexts/static/json/Notices.json') + notice_data = json.load(notice_json_data) #deserialize + + baseURL = f'https://storage.googleapis.com/{settings.STORAGE_BUCKET}/labels/notices/' + for item in notice_data: + notice_type = item['noticeType'] + if notice_type == 'open_to_collaborate': + name = item['noticeName'] + img_url = baseURL + item['imgFileName'] + svg_url = baseURL + item['svgFileName'] + default_text = item['noticeDefaultText'] + + with open( + './localcontexts/static/json/NoticeTranslations.json', encoding="utf8" + ) as translations_json_data: + translations_data = json.load(translations_json_data) + + translations = [] + for item in translations_data: + notice_type = item['noticeType'] + if notice_type == 'open_to_collaborate': + translated_name = item['noticeName'] + language_tag = item['languageTag'] + language = item['language'] + translated_text = item['noticeDefaultText'] + notice_translation = { + 'translated_name': translated_name, + 'language_tag': language_tag, + 'language': language, + 'language': language, + 'translated_text': translated_text + } + translations.append(notice_translation) + + notice = { + 'notice_type': notice_type, + 'name': name, + 'img_url': img_url, + 'svg_url': svg_url, + 'default_text': default_text, + 'notice_translations': translations, + 'created': '2021-10-05T00:00:00.000Z', + 'updated': '2021-10-05T00:00:00.000Z' + } + + data = { + 'notice': notice + } + + if request.user.researcher: + data['researcher'] = request.user.researcher + data['institution'] = None + elif request.user.institution: + data['institution'] = request.user.institution + data['researcher'] = None + + serializer = v2_serializers.OpenToCollaborationSerializer( + data, context={'request': request} + ) + return Response(serializer.data) + else: + raise PermissionDenied(ERROR_MESSAGE_CHOICES['denied_call']) + +@extend_schema( + request=v2_serializers.ProjectOverviewSerializer, + description="Get a list of all Projects available through the Hub.", + responses={ + 200: v2_serializers.ProjectOverviewSerializer, + 403: OpenApiResponse(description='Error: Forbidden'), + }, +) class ProjectList(generics.ListAPIView): - permission_classes = [HasAPIKey] - queryset = Project.objects.exclude(project_privacy='Private') - serializer_class = ProjectOverviewSerializer + authentication_classes = [APIKeyAuthentication] + + serializer_class = v2_serializers.ProjectOverviewSerializer + filter_backends = [IsActiveCreatorFilter, DjangoFilterBackend,] + filterset_class = ProjectListFilterSet - filter_backends = [filters.SearchFilter] - search_fields = ['^providers_id', '=unique_id', '$title'] + def get_queryset(self): + queryset = self.filter_queryset(Project.objects.filter(project_privacy='Public')) + return queryset - # '^' starts-with search - # '=' exact matches - # '$' regex search +@extend_schema( + request=v2_serializers.ProjectSerializer, + description="Loads the Project information based on the Project's unique_id.", + responses={ + 200: v2_serializers.ProjectSerializer, + 403: OpenApiResponse(description='Error: Forbidden'), + 404: OpenApiResponse(description='Error: Not Found'), + }, + parameters=[ + OpenApiParameter( + name="unique_id", description=HELPTEXT_CHOICES['unique_id'], required=True, type=OpenApiTypes.UUID, location="path" + ) + ], +) class ProjectDetail(generics.RetrieveAPIView): - permission_classes = [HasAPIKey | IsAuthenticated] + authentication_classes = [APIKeyAuthentication] + serializer_class = v2_serializers.ProjectSerializer + filter_backends = [IsActiveCreatorFilter] lookup_field = 'unique_id' - queryset = Project.objects.exclude(project_privacy='Private') - def get_serializer_class(self): - project = self.get_object() - if Notice.objects.filter(project=project, archived=False).exists(): - return ProjectSerializer - else: - return ProjectNoNoticeSerializer - + def get_queryset(self): + queryset = self.filter_queryset(Project.objects.filter(project_privacy='Public')) + return queryset + def get_object(self): try: + queryset = self.get_queryset() unique_id = self.kwargs.get('unique_id') - obj = self.queryset.get(unique_id=unique_id) - return obj + if queryset.filter(unique_id=unique_id).exists(): + return queryset.get(unique_id=unique_id) + elif Project.objects.get(unique_id=unique_id): + raise PermissionDenied(ERROR_MESSAGE_CHOICES['denied_call']) + except Project.DoesNotExist: - raise Http404("Project does not exist") + raise Http404(ERROR_MESSAGE_CHOICES['no_project']) + # TODO: check to see why this message won't show properly -class ProjectsByIdViewSet(ViewSet): - permission_classes = [HasAPIKey | IsAuthenticated] - def projects_by_user(self, request, pk): - try: - useracct = User.objects.get(id=pk) - projects = Project.objects.filter(project_creator=useracct).exclude(project_privacy='Private') - serializer = ProjectOverviewSerializer(projects, many=True) - return Response(serializer.data) - - except: - return Response(status=status.HTTP_404_NOT_FOUND) - def projects_by_institution(self, request, institution_id, providers_id=None): - try: - institution = Institution.objects.get(id=institution_id) - - projects = [] - creators = ProjectCreator.objects.filter(institution=institution) - if providers_id != None: - for x in creators: - if x.project.providers_id == providers_id: - projects.append(x.project) - else: - for x in creators: - projects.append(x.project) - - serializer = ProjectOverviewSerializer(projects, many=True) - return Response(serializer.data) - except: - return Response(status=status.HTTP_404_NOT_FOUND) +class MultiProjectListDetail(ViewSet): + authentication_classes = [APIKeyAuthentication] + filter_backends = [IsActiveCreatorFilter] + serializer_class = v2_serializers.ProjectSerializer + + def get_queryset(self): + for backend in list(self.filter_backends): + queryset = backend().filter_queryset( + self.request, Project.objects.filter(project_privacy='Public'), self + ) + return queryset - def projects_by_researcher(self, request, researcher_id): + @extend_schema( + request=v2_serializers.ProjectSerializer, + parameters=[ + OpenApiParameter( + "unique_id", required=True, type=OpenApiTypes.UUID, many=True, + location=OpenApiParameter.PATH, description=HELPTEXT_CHOICES['unique_id'] + ) + ], + description="Loads the Project information for all Projects whose unique_id is " \ + "included at the end of the path. NOTE: All unique_ids must be added with a comma " \ + "separating each unique_id (no spaces).", + responses={ + 200: v2_serializers.ProjectSerializer, + 403: OpenApiResponse(description='Error: Forbidden'), + 404: OpenApiResponse(description='Error: Not Found'), + }, + ) + def multisearch(self, request, unique_id): try: - researcher = Researcher.objects.get(id=researcher_id) + unique_id = unique_id.split(',') + query= Q() + for x in unique_id: + q = Q(unique_id=x) + query |= q + project=self.get_queryset().filter(query) - projects = [] - creators = ProjectCreator.objects.filter(researcher=researcher) - for x in creators: - projects.append(x.project) + if not project: + if Project.objects.filter(project).exists(): + return Response( + {"detail": ERROR_MESSAGE_CHOICES['denied_project']}, + status=status.HTTP_403_FORBIDDEN) + else: + return Response( + {"detail": ERROR_MESSAGE_CHOICES['no_project']}, + status=status.HTTP_404_NOT_FOUND) - serializers = ProjectOverviewSerializer(projects, many=True) - return Response(serializers.data) - except: - return Response(status=status.HTTP_404_NOT_FOUND) + serializer = self.serializer_class(project, many=True, context={"request": request}) -#TODO: remove this function or convert it so that the project detail (list view) can be used using either projectID or providersID. Two URLs that use one call. projects/external url would be removed -# Make this a filter instead? - def project_detail_providers(self, request, providers_id): - try: - project = Project.objects.get(providers_id=providers_id) - if project.project_privacy == 'Public' or project.project_privacy == 'Contributor': - if project.has_notice(): - serializer = ProjectSerializer(project, many=False) - else: - serializer = ProjectNoNoticeSerializer(project, many=False) - - return Response(serializer.data) - else: - raise PermissionDenied({"message":"You don't have permission to view this project", "providers_id": providers_id}) + return Response(serializer.data) except: - return Response(status=status.HTTP_404_NOT_FOUND) - -class MultiProjectListDetail(ViewSet): - permission_classes = [HasAPIKey | IsAuthenticated] + return Response( + {"detail": ERROR_MESSAGE_CHOICES['invalid_id']}, + status=status.HTTP_404_NOT_FOUND) - def multisearch(self, request, unique_id): - try: - project = Project.objects.all() - - if unique_id is not None: - unique_id = unique_id.split(',') - query= Q() - for x in unique_id: - q = Q(unique_id=x) - query |= q - project=project.filter(query).exclude(project_privacy='Private') - notices = project.filter(Q(project_notice__isnull=False) & (Q(bc_labels__isnull=True) & Q(tk_labels__isnull=True))) - labels = project.filter(Q(bc_labels__isnull=False) | Q(tk_labels__isnull=False)).distinct() - no_notice_labels = project.filter(Q(project_notice__isnull=True) & (Q(bc_labels__isnull=True) & Q(tk_labels__isnull=True))).distinct() - - notices_serializer = ProjectSerializer(notices, many=True) - labels_serializer = ProjectNoNoticeSerializer(labels, many=True) - no_notice_labels_serializer = ProjectNoNoticeSerializer(no_notice_labels, many=True) - - return Response({ - "notices_only":notices_serializer.data, - "labels_only":labels_serializer.data, - "no_labels_or_notices":no_notice_labels_serializer.data - }) - except: - return Response(status=status.HTTP_404_NOT_FOUND) - + @extend_schema( + request=v2_serializers.ProjectDateModified, + parameters=[ + OpenApiParameter( + "unique_id", required=True, type=OpenApiTypes.UUID, many=True, + location=OpenApiParameter.PATH, description=HELPTEXT_CHOICES['unique_id'] + ) + ], + description="Loads the Project's date modified information for all Projects whose " \ + "unique_id is included at the end of the path. NOTE: All unique_ids must be added " \ + "with a comma separating each unique_id (no spaces).", + responses={ + 200: v2_serializers.ProjectDateModified, + 403: OpenApiResponse(description='Error: Forbidden'), + 404: OpenApiResponse(description='Error: Not Found'), + }, + ) def multisearch_date(self, request, unique_id): try: - project = Project.objects.all() + unique_id = unique_id.split(',') + query= Q() + for x in unique_id: + q = Q(unique_id=x) + query |= q + project=self.get_queryset().filter(query) - if unique_id is not None: - unique_id = unique_id.split(',') - query= Q() - for x in unique_id: - q = Q(unique_id=x) - query |= q - project=project.filter(query).exclude(project_privacy='Private') + if not project: + if Project.objects.filter(query).exists(): + return Response( + {"detail": ERROR_MESSAGE_CHOICES['denied_project']}, + status=status.HTTP_403_FORBIDDEN) + else: + return Response( + {"detail": ERROR_MESSAGE_CHOICES['no_project']}, + status=status.HTTP_404_NOT_FOUND) - serializer = ProjectDateModified(project, many=True) + serializer = v2_serializers.ProjectDateModified(project, many=True) return Response(serializer.data) except: - return Response(status=status.HTTP_404_NOT_FOUND) + return Response( + {"detail": ERROR_MESSAGE_CHOICES['invalid_id']}, + status=status.HTTP_404_NOT_FOUND) +# SALESFORCE CALLS +@extend_schema(exclude=True) class GetUserAPIView(APIView): - authentication_classes = [ApiKeyAuthentication] - permission_classes = [IsAuthenticated] + authentication_classes = [APIKeyAuthentication] + permission_classes = [IsSuperuser] def get(self, request): email = request.query_params.get('email', None) if not email: - return Response({"error": "Email parameter is required in the query parameters."}, status=status.HTTP_400_BAD_REQUEST) + return Response( + {"error": "Email parameter is required in the query parameters."}, + status=status.HTTP_400_BAD_REQUEST + ) try: user = User.objects.get(email=email) - serializer = GetUserSerializer(user) + serializer = base_serializers.GetUserSerializer(user) return Response(serializer.data) except User.DoesNotExist: - return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND) \ No newline at end of file + return Response({"error": "User not found"}, status=status.HTTP_404_NOT_FOUND) + +@extend_schema(exclude=True) +class SubscriptionAPI(APIView): + authentication_classes = [APIKeyAuthentication] + permission_classes = [IsSuperuser] + + def get(self, request): + date_last_updated = request.query_params.get('date_last_updated') + if date_last_updated is not None: + try: + date_last_updated = datetime.strptime(date_last_updated, '%Y-%m-%dT%H:%M:%SZ') + except ValueError: + return Response( + {'error': 'Invalid date format. Use YYYY-MM-DDTHH:MM:SSZ'}, + status=status.HTTP_400_BAD_REQUEST + ) + + subscriptions = Subscription.objects.filter(date_last_updated__gt=date_last_updated) + serializer = base_serializers.GetSubscriptionSerializer(subscriptions, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + return Response( + {'error': 'date_last_updated parameter is required'}, + status=status.HTTP_400_BAD_REQUEST + ) + + + def post(self, request): + try: + account_id = request.data.get('account_id') + hub_id , account_type = account_id.split('_') + user_count = request.data.get('users_count') + api_key_count = request.data.get('api_key_count') + project_count = request.data.get('project_count') + notification_count = request.data.get('notification_count') + start_date = request.data.get('start_date') + end_date = request.data.get('end_date') + date_last_updated = request.data.get('date_last_updated') + + account_type_to_field = { + 'i': 'institution_id', + 'c': 'community_id', + 'r': 'researcher_id' + } + field_to_model = { + 'institution_id': Institution, + 'community_id': Community, + 'researcher_id': Researcher + } + + field_name = account_type_to_field.get(account_type) + model_class = field_to_model.get(field_name) + if not field_name: + return Response( + {'error': 'Failed to create Subscription. Invalid account_type provided.'}, + status=status.HTTP_400_BAD_REQUEST + ) + + filter_kwargs = { + field_name: hub_id, + 'defaults': { + 'users_count': user_count, + 'api_key_count': api_key_count, + 'project_count': project_count, + 'notification_count': notification_count, + 'start_date': start_date, + 'end_date': end_date, + 'date_last_updated': date_last_updated + } + } + with transaction.atomic(): + subscription, created = Subscription.objects.get_or_create(**filter_kwargs) + if created: + subscriber = get_object_or_404(model_class, id=hub_id) + subscriber.is_subscribed = True + subscriber.save() + return Response( + {'success': 'The record is created.'}, status=HTTP_201_CREATED + ) + else: + subscription.users_count = user_count + subscription.api_key_count = api_key_count + subscription.project_count = project_count + subscription.notification_count = notification_count + subscription.start_date = start_date + subscription.end_date = end_date + subscription.date_last_updated = date_last_updated + subscription.save() + return Response({'success': 'The record is updated.'},status=HTTP_200_OK) + + except IntegrityError as e: + if 'violates foreign key constraint' in str(e): + return Response( + {'error': 'Failed to create Subscription. This record violates foreign key constraint.'}, + status=status.HTTP_400_BAD_REQUEST + ) + else: + return Response( + {'error': 'An unexpected error occurred.'}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR + ) \ No newline at end of file diff --git a/api/views.py b/api/views.py index 7bb6452c4..332b7a700 100644 --- a/api/views.py +++ b/api/views.py @@ -1,6 +1,63 @@ from django.shortcuts import redirect current_version = '/api/v1/' + def redirect_view(request): response = redirect(current_version) return response + + +HELPTEXT_CHOICES = { + 'institution': "An object containing information on an Institution.", + 'researcher': "An object containing information on a Researcher.", + 'community': "An object containing information on a Community.", + 'id': "The Hub ID of an account.", + 'account_name': "The name of an Institution, Community, or Researcher.", + 'person_name': "The name of a person associated with a Project as a contact or non-Hub contributor.", + 'name': "The name of a Notice or Label.", + 'profile_url': "The URL of an account's public page in the Registry.", + 'ror_id': "The URL of an Institution's ROR ID. If the Institution does not have a ROR ID, this field will be `NULL`.", + 'orcid': "The URL of a Researcher's ORCID. If a Researcher has not linked an ORCID to their profile, this field will be `NULL`.", + 'email': "The email of a Project's contact or non-Hub contributor.", + 'institutions': "An array of `institution` objects.", + 'communities': "An array of `community` objects.", + 'researchers': "An array of `researcher` objects.", + 'others': "An array of `others` objects.", + 'created_by': "An array detailing who created a Project.", + 'unique_id': "The unique_id of a Project or Label.", + 'title': "The title of a Project.", + 'project_privacy': "The privacy setting set for a Project.", + 'date_added': "The date a Project was created in the Hub.", + 'date_modified': "The date a Project was modified. If a Project was never modified, the date will be the same as `date_added`.", + 'external_ids': "An object of external identifiers related to a Project.", + 'providers_id': "An external identifier for a Project added by the user, like a catalog or accession number that is already being used in a different system from the Local Contexts Hub.", + 'publication_doi': "A persistent, unique identifier commonly assigned to digital publications, such as ebooks and journal articles.", + 'project_data_guid': "A Globally Unique Identifier, such as an ARK identifier.", + 'contributors': "An array of objects detailing additional contributors to a project other than the Project Creator.", + 'notice': "An array of objects containing Notice information.", + 'bc_labels': "An array of objects containing BC Label information.", + 'tk_labels': "An array of objects containing TK Label information.", + 'project_contact': "An array of contact information for the person who should be contacted regarding a Project.", + 'related_projects': "An array with `unique_id`s for all Related Projects connected to a Project. If no Related Projects are connected, this array will be empty.", + 'sub_projects': "An array with `unique_id`s for all Sub Projects connected to a Project. If no Sub Projects are connected, this array will be empty.", + 'label_type': "The type of Label.", + 'language_tag': "The shortened indication of the language the text of a Label is written in. If left blank, this field will be a string with no characters in it.", + 'language': "The full name of the language the text of a Label is written in. If left blank, this field will be a string with no characters in it.", + 'label_text': "The customized text for a Label.", + 'img_url': "The URL for a Notice or Label icon as a PNG image.", + 'svg_url': "The URL for a Notice or Label icon as a SVG file.", + 'audiofile': "The URL for an audio file for a Label. If an audio file was not added, then this field will be `NULL`.", + 'translations': "An array with information on translation(s) of a Notice or Label. If no information is available, this array will be empty.", + 'created': "The date a Notice or Label was added to a Project, or the date a Project was created. If previous Notice(s) or Label(s) are completely removed and replaced the created date will reflect when the new Notice(s) or Label(s) was placed.", + 'updated': "The date a new Notice or Label was added/updated/removed from a Project.", + 'notice_type': "The type of Notice.", + 'default_text': "The default text for Notices.", + 'source_project_uuid': "The `source_project_uuid` for a Project (only applicable to Sub Projects). If this Project is not a Sub Project, then the data type will be `NULL`.", + 'project_page': "A URL link for a Project view page.", + 'description': "The description of a Project.", + 'project_type': "The type selected for a Project.", + 'urls': "External links related to a Project.", + 'project_boundary_geojson': "A GeoJSON array. GeoJSON is a format for encoding a variety of geographic data structures. If no information is available, this array will be empty.", + 'translated_name': "The translated name of a Notice or Label.", + 'translated_text': "The translated text of a Notice or Label.", +} diff --git a/cloudbuild-develop.yaml b/cloudbuild-develop.yaml index 61f94f91c..3665c75a3 100644 --- a/cloudbuild-develop.yaml +++ b/cloudbuild-develop.yaml @@ -61,7 +61,6 @@ steps: export DB_HOST=$$DB_SQL_PROXY /workspace/cloud_sql_proxy -dir=/workspace -instances=$$DB_CONNECTION_NAME \ & sleep 2 \ - && python3 ./manage.py makemigrations \ && python3 ./manage.py migrate waitFor: ["setup-envs", "install-deps", "install-proxy", "deploy"] diff --git a/cloudbuild-master.yaml b/cloudbuild-master.yaml index 54d9b7e76..b3dbe19fe 100644 --- a/cloudbuild-master.yaml +++ b/cloudbuild-master.yaml @@ -57,7 +57,6 @@ steps: export DB_HOST=$$DB_SQL_PROXY /workspace/cloud_sql_proxy -dir=/workspace -instances=$$DB_CONNECTION_NAME \ & sleep 2 \ - && python3 ./manage.py makemigrations \ && python3 ./manage.py migrate waitFor: ["setup-envs", "install-deps", "install-proxy", "deploy"] availableSecrets: diff --git a/cloudbuild.yaml b/cloudbuild.yaml index 36f9db55f..5a8c5bd8f 100644 --- a/cloudbuild.yaml +++ b/cloudbuild.yaml @@ -61,7 +61,6 @@ steps: export DB_HOST=$$DB_SQL_PROXY /workspace/cloud_sql_proxy -dir=/workspace -instances=$$DB_CONNECTION_NAME \ & sleep 2 \ - && python3 ./manage.py makemigrations \ && python3 ./manage.py migrate waitFor: ["setup-envs", "install-deps", "install-proxy", "deploy"] diff --git a/communities/forms.py b/communities/forms.py index 81912954d..f25bc9fde 100644 --- a/communities/forms.py +++ b/communities/forms.py @@ -15,13 +15,15 @@ class CreateCommunityForm(forms.ModelForm): class Meta: model = Community - fields = ['community_name', 'community_entity', 'state_province_region', 'country', 'description', 'website'] + fields = ['community_name', 'community_entity', 'state_province_region', 'country', 'description', 'website', 'contact_name', 'contact_email'] widgets = { 'community_name': forms.TextInput(attrs={'class': 'w-100'}), 'community_entity': forms.TextInput(attrs={'class': 'w-100'}), 'state_province_region': forms.TextInput(attrs={'class': 'w-100'}), 'description': forms.Textarea(attrs={'rows': 2, 'class': 'w-100'}), 'website': forms.TextInput(attrs={'class': 'w-100'}), + 'contact_name': forms.TextInput(attrs={'class': 'w-100', 'required': True}), + 'contact_email': forms.EmailInput(attrs={'class': 'w-100', 'required': True, 'id': 'communityContactEmailField'}), } error_messages = { 'community_name': { @@ -29,30 +31,6 @@ class Meta: }, } -class ConfirmCommunityForm(forms.ModelForm): - class Meta: - model = Community - fields = ['contact_name', 'contact_email', 'support_document'] - widgets = { - 'contact_name': forms.TextInput(attrs={'class': 'w-100'}), - 'contact_email': forms.EmailInput(attrs={'class': 'w-100', 'id': 'communityContactEmailField'}), - 'support_document': forms.ClearableFileInput(attrs={'class': 'w-100 hide', 'id': 'communitySupportLetterUploadBtn', 'onchange': 'showFileName()'}), - } - - def clean_support_document(self): - support_document_file = self.cleaned_data.get('support_document') - if support_document_file: - allowed_extensions = ['.pdf', '.doc', '.docx'] # Add more extensions if needed - file_ext = os.path.splitext(support_document_file.name)[1].lower() - - if file_ext not in allowed_extensions: - raise ValidationError('Invalid document file extension. Only PDF and DOC/DOCX files are allowed.') - - allowed_mime_types = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'] - if support_document_file.content_type not in allowed_mime_types: - raise ValidationError('Invalid document file type. Only PDF and DOC/DOCX files are allowed.') - return support_document_file - class CommunityModelForm(forms.ModelForm): class Meta: @@ -173,6 +151,22 @@ class Meta: 'role': forms.Select(attrs={'class': 'w-100'}), 'message': forms.Textarea(attrs={'rows': 2, 'class':'w-100'}), } + + def __init__(self, *args, **kwargs): + subscription = kwargs.pop('subscription', None) + service_provider = kwargs.pop('service_provider', None) + super().__init__(*args, **kwargs) + if subscription is not None and subscription.users_count == 0: + modified_choices = [ + ('viewer', 'Viewer'), + ] + self.fields['role'].choices = modified_choices + + if service_provider is not None: + modified_choices = [ + ('editor', 'Editor'), + ] + self.fields['role'].choices = modified_choices class JoinRequestForm(forms.ModelForm): class Meta: diff --git a/communities/migrations/0051_alter_invitemember_role.py b/communities/migrations/0051_alter_invitemember_role.py new file mode 100644 index 000000000..76d85c5dd --- /dev/null +++ b/communities/migrations/0051_alter_invitemember_role.py @@ -0,0 +1,26 @@ +# Generated by Django 4.2.11 on 2024-04-18 18:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("communities", "0050_merge_0048_auto_20240123_0046_0049_alter_boundary_id"), + ] + + operations = [ + migrations.AlterField( + model_name="invitemember", + name="role", + field=models.CharField( + choices=[ + ("", "---------"), + ("admin", "admin"), + ("editor", "editor"), + ("viewer", "viewer"), + ], + max_length=8, + null=True, + ), + ), + ] diff --git a/communities/migrations/0052_merge_20240613_1240.py b/communities/migrations/0052_merge_20240613_1240.py new file mode 100644 index 000000000..2a143ecfe --- /dev/null +++ b/communities/migrations/0052_merge_20240613_1240.py @@ -0,0 +1,14 @@ +# Generated by Django 5.0.6 on 2024-06-13 16:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('communities', '0051_alter_invitemember_role'), + ('communities', '0051_community_share_boundary_publicly'), + ] + + operations = [ + ] diff --git a/communities/migrations/0053_remove_invitemember_communities_sender__b17605_idx_and_more.py b/communities/migrations/0053_remove_invitemember_communities_sender__b17605_idx_and_more.py new file mode 100644 index 000000000..f88b236d3 --- /dev/null +++ b/communities/migrations/0053_remove_invitemember_communities_sender__b17605_idx_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.0.6 on 2024-07-09 19:15 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('communities', '0052_merge_20240613_1240'), + ('institutions', '0035_remove_institution_is_submitted'), + ('serviceproviders', '0002_serviceprovider_documentation_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveIndex( + model_name='invitemember', + name='communities_sender__b17605_idx', + ), + migrations.AddField( + model_name='invitemember', + name='service_provider', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='service_provider_invitation', to='serviceproviders.serviceprovider'), + ), + migrations.AddIndex( + model_name='invitemember', + index=models.Index(fields=['sender', 'receiver', 'community', 'institution', 'service_provider'], name='communities_sender__ff1b4a_idx'), + ), + ] diff --git a/communities/migrations/0054_community_show_sp_connection.py b/communities/migrations/0054_community_show_sp_connection.py new file mode 100644 index 000000000..93659835c --- /dev/null +++ b/communities/migrations/0054_community_show_sp_connection.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-08-23 16:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('communities', '0053_remove_invitemember_communities_sender__b17605_idx_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='community', + name='show_sp_connection', + field=models.BooleanField(default=True), + ), + ] diff --git a/communities/migrations/0055_community_sp_privacy.py b/communities/migrations/0055_community_sp_privacy.py new file mode 100644 index 000000000..fb88b7c57 --- /dev/null +++ b/communities/migrations/0055_community_sp_privacy.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-09-11 17:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('communities', '0054_community_show_sp_connection'), + ] + + operations = [ + migrations.AddField( + model_name='community', + name='sp_privacy', + field=models.CharField(choices=[('public', 'Public/Contributor'), ('all', 'All')], default='all', max_length=20), + ), + ] diff --git a/communities/migrations/0056_community_is_member.py b/communities/migrations/0056_community_is_member.py new file mode 100644 index 000000000..3cb40baee --- /dev/null +++ b/communities/migrations/0056_community_is_member.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.9 on 2024-10-09 16:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('communities', '0055_community_sp_privacy'), + ] + + operations = [ + migrations.AddField( + model_name='community', + name='is_member', + field=models.BooleanField(default=False), + ), + ] diff --git a/communities/models.py b/communities/models.py index 0a472f1eb..59c323311 100644 --- a/communities/models.py +++ b/communities/models.py @@ -4,6 +4,7 @@ from django.contrib.auth.models import User from django_countries.fields import CountryField from institutions.models import Institution +from serviceproviders.models import ServiceProvider import uuid from itertools import chain import os @@ -46,6 +47,11 @@ def get_coordinates(self, as_tuple=True): class Community(models.Model): + PRIVACY_LEVEL = ( + ('public', 'Public/Contributor'), + ('all', 'All'), + ) + community_creator = models.ForeignKey(User, on_delete=models.CASCADE, null=True) community_name = models.CharField(max_length=80, null=True, unique=True) community_entity = models.CharField(max_length=200, null=True, blank=True) @@ -53,7 +59,7 @@ class Community(models.Model): contact_email = models.EmailField(max_length=254, null=True, blank=True) image = models.ImageField(upload_to=community_img_path, blank=True, null=True) support_document = models.FileField(upload_to=get_file_path, blank=True, null=True) - description = models.TextField(null=True, blank=True, validators=[MaxLengthValidator(200)]) + description = models.TextField(null=True, validators=[MaxLengthValidator(200)]) city_town = models.CharField(max_length=80, blank=True, null=True) state_province_region = models.CharField(verbose_name='state or province', max_length=100, blank=True, null=True) country = CountryField(blank=True, null=True) @@ -64,19 +70,23 @@ class Community(models.Model): is_approved = models.BooleanField(default=False, null=True) approved_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="community_approver") created = models.DateTimeField(auto_now_add=True, null=True) + is_member = models.BooleanField(default=False) source_of_boundary = models.CharField(max_length=400, blank=True, null=True) name_of_boundary = models.CharField(max_length=200, blank=True, null=True) - boundary = models.ForeignKey(Boundary, on_delete=models.CASCADE, null=True) + boundary = models.ForeignKey(Boundary, on_delete=models.CASCADE, blank=True, null=True) share_boundary_publicly = models.BooleanField(default=True) + show_sp_connection = models.BooleanField(default=True, null=True) + sp_privacy = models.CharField(max_length=20, default='all', choices=PRIVACY_LEVEL, null=True) + # Managers objects = models.Manager() approved = ApprovedManager() def get_location(self): components = [self.city_town, self.state_province_region, self.country.name] - location = ', '.join(filter(None, components)) or 'None specified' + location = ', '.join(filter(None, components)) or None return location def get_member_count(self): @@ -132,6 +142,7 @@ class InviteMember(models.Model): ) ROLES = ( + ("", "---------"), ('admin', 'admin'), ('editor', 'editor'), ('viewer', 'viewer'), @@ -141,6 +152,7 @@ class InviteMember(models.Model): receiver = models.ForeignKey(User, on_delete=models.CASCADE, related_name='receiver') community = models.ForeignKey(Community, on_delete=models.CASCADE, related_name='community_invitation', null=True, blank=True) institution = models.ForeignKey(Institution, on_delete=models.CASCADE, related_name='institution_invitation', null=True, blank=True) + service_provider = models.ForeignKey(ServiceProvider, on_delete=models.CASCADE, related_name='service_provider_invitation', null=True, blank=True) role = models.CharField(max_length=8, choices=ROLES, null=True) message = models.TextField(blank=True, null=True) status = models.CharField(max_length=8, choices=STATUS_CHOICES, default='sent', blank=True) @@ -151,7 +163,7 @@ def __str__(self): return f"{self.sender}-{self.receiver}-{self.status}" class Meta: - indexes = [models.Index(fields=['sender', 'receiver', 'community', 'institution'])] + indexes = [models.Index(fields=['sender', 'receiver', 'community', 'institution', 'service_provider'])] verbose_name = 'Member Invitation' verbose_name_plural = 'Member Invitations' ordering = ('-created',) diff --git a/communities/templatetags/custom_tags.py b/communities/templatetags/custom_tags.py index dbfba6a97..54e8bcf9d 100644 --- a/communities/templatetags/custom_tags.py +++ b/communities/templatetags/custom_tags.py @@ -1,43 +1,10 @@ from django import template from django.urls import reverse from django.templatetags.static import static -from notifications.models import ActionNotification -from bclabels.models import BCLabel -from tklabels.models import TKLabel -from itertools import chain import os register = template.Library() -# How many total Labels have been applied to projects -@register.simple_tag -def get_label_count(community): - count = 0 - # Get all labels in this community - # check to see if label exists in projects - for label in BCLabel.objects.prefetch_related('project_bclabels').filter(community=community): - count = count + label.project_bclabels.count() - for label in TKLabel.objects.prefetch_related('project_tklabels').filter(community=community): - count = count + label.project_tklabels.count() - return count - -# How many Projects has this community been notified of -@register.simple_tag -def community_notified_count(community): - return community.communities_notified.count() - -# How many connections gave been created (how many unique institutions or researchers have had a Label applied to a project) -@register.simple_tag -def connections_count(community): - contributor_ids = list(chain( - community.contributing_communities.exclude(institutions__id=None).values_list('institutions__id', flat=True), - community.contributing_communities.exclude(researchers__id=None).values_list('researchers__id', flat=True), - )) - return len(contributor_ids) - -# @register.simple_tag -# def anchor(url_name, section_id, community_id): -# return reverse(url_name, kwargs={'pk': community_id}) + "#project-unique-" + str(section_id) @register.simple_tag def get_bclabel_img_url(img_type, *args, **kwargs): diff --git a/communities/urls.py b/communities/urls.py index dd256dba1..acd8f9ef1 100644 --- a/communities/urls.py +++ b/communities/urls.py @@ -2,56 +2,67 @@ from . import views urlpatterns = [ + # Creating/Joining Accounts and adding Boundaries path('preparation-step/', views.preparation_step, name="prep-community"), path('connect-community/', views.connect_community, name="connect-community"), path('create-community/', views.create_community, name="create-community"), path('community-boundary/', views.community_boundary, name="community-boundary"), path('add-community-boundary/', views.add_community_boundary, name="add-community-boundary"), path('upload-boundary-file/', views.upload_boundary_file, name="upload-boundary-file"), - path('confirm-community/', views.confirm_community, name="confirm-community"), path('registration-boundary', views.registration_boundary, name="registration-boundary"), # Public view path('view//', views.public_community_view, name="public-community"), + # Settings path('update//', views.update_community, name="update-community"), - path('reset-community_boundary//', views.reset_community_boundary, name="reset-community_boundary"), + path('preferences//', views.account_preferences, name="preferences-community"), + path('reset-community-boundary//', views.reset_community_boundary, name="reset-community-boundary"), path('update-community-boundary//', views.update_community_boundary, name="update-community-boundary"), path('update-community-boundary-data//', views.update_community_boundary_data, name="update-community-boundary-data" ), + path('api-key//', views.api_keys, name="community-api-key"), + path('connect-service-provider//', views.connect_service_provider, name="community-connect-service-provider"), - path('members//', views.community_members, name="members"), - path('members/requests//', views.member_requests, name="member-requests"), - path('members/remove//', views.remove_member, name="remove-member"), - - path('members/join-request/delete///', views.delete_join_request, name="delete-join-request"), - + # Labels: View path('labels/select//', views.select_label, name="select-label"), path('labels/view///', views.view_label, name="view-label"), + # Labels: Customize path('labels/customize///', views.customize_label, name="customize-label"), path('labels///', views.approve_label, name="approve-label"), path('labels/edit///', views.edit_label, name="edit-label"), + # Labels: Apply path('labels/apply-labels///', views.apply_labels, name="apply-labels"), + #Labels: Export + path('labels-pdf//', views.labels_pdf, name="labels-pdf"), + path('labels-download//', views.download_labels, name="download-labels"), + + # Members + path('members//', views.community_members, name="members"), + path('members/requests//', views.member_requests, name="member-requests"), + path('members/remove//', views.remove_member, name="remove-member"), + path('members/join-request/delete///', views.delete_join_request, name="delete-join-request"), + + # Projects: View path('projects//', views.projects, name="community-projects"), - path('projects/create-project///', views.create_project, name="create-project"), - path('projects/create-project///', views.create_project, name="create-project"), - path('projects/create-project//', views.create_project, name="create-project"), - + # Projects: Create + path('projects/create-project///', views.create_project, name="community-create-project"), + path('projects/create-project///', views.create_project, name="community-create-project"), + path('projects/create-project//', views.create_project, name="community-create-project"), + + # Projects: Edit path('projects/edit-project///', views.edit_project, name="edit-project"), path('projects/actions///', views.project_actions, name="community-project-actions"), path('projects/delete-project//', views.delete_project, name="community-delete-project"), path('projects/archive-project//', views.archive_project, name="community-archive-project"), - path('projects/unlink///', views.unlink_project, name="community-unlink-project"), + # Connections path('connections//', views.connections, name="community-connections"), - - path('labels-pdf//', views.labels_pdf, name="labels-pdf"), - path('labels-download//', views.download_labels, name="download-labels"), ] \ No newline at end of file diff --git a/communities/utils.py b/communities/utils.py index d745159dc..e15205630 100644 --- a/communities/utils.py +++ b/communities/utils.py @@ -1,5 +1,6 @@ from functools import wraps -from django.shortcuts import render +from django.shortcuts import render, redirect +from django.contrib import messages from .models import Community from bclabels.utils import check_bclabel_type @@ -8,7 +9,10 @@ from tklabels.forms import CustomizeTKLabelForm from bclabels.models import BCLabel from tklabels.models import TKLabel - +from helpers.models import HubActivity +from accounts.models import UserAffiliation +from django.db import transaction +from helpers.utils import handle_confirmation_and_subscription def get_community(pk): return Community.objects.select_related('community_creator').prefetch_related('admins', 'editors', 'viewers').get( @@ -44,3 +48,31 @@ def wrap(request, *args, **kwargs): return function(request, *args, **kwargs) return wrap + +def handle_community_creation(request, data, subscription_form, env): + try: + with transaction.atomic(): + data.save() + handle_confirmation_and_subscription(request, subscription_form, data, env) + # Add to user affiliations + affiliation = UserAffiliation.objects.prefetch_related('communities').get(user=request.user) + affiliation.communities.add(data) + affiliation.save() + + # Adds activity to Hub Activity + HubActivity.objects.create( + action_user_id=request.user.id, + action_type="New Community", + community_id=data.id, + action_account_type='community' + ) + request.session['new_community_id'] = data.id + + except Exception as e: + messages.add_message( + request, + messages.ERROR, + "An unexpected error has occurred here." + " Please contact support@localcontexts.org.", + ) + return redirect('dashboard') \ No newline at end of file diff --git a/communities/views.py b/communities/views.py index 677520c67..048506ec0 100644 --- a/communities/views.py +++ b/communities/views.py @@ -4,18 +4,20 @@ from itertools import chain from django.contrib.auth.models import User -from accounts.models import UserAffiliation +from accounts.models import UserAffiliation, ServiceProviderConnections from helpers.models import * from notifications.models import * from bclabels.models import BCLabel from tklabels.models import TKLabel from projects.models import * +from api.models import AccountAPIKey from helpers.forms import * from bclabels.forms import * from tklabels.forms import * from projects.forms import * from accounts.forms import ContactOrganizationForm, SignUpInvitationForm +from api.forms import APIKeyGeneratorForm from localcontexts.utils import dev_prod_or_local from projects.utils import * @@ -32,7 +34,8 @@ from .utils import * # pdfs -from django.http import HttpResponse, Http404 +from django.http import HttpResponse, Http404, HttpResponseRedirect +from django.urls import reverse from django.template.loader import get_template from xhtml2pdf import pisa @@ -117,13 +120,28 @@ def preparation_step(request): @login_required(login_url='login') def create_community(request): form = CreateCommunityForm(request.POST or None) + user_form = form_initiation(request) + env = dev_prod_or_local(request.get_host()) + if request.method == "POST": - if form.is_valid(): + if form.is_valid() and user_form.is_valid() and validate_recaptcha(request): data = form.save(commit=False) data.community_creator = request.user + mutable_post_data = request.POST.copy() + subscription_data = { + "first_name": user_form.cleaned_data['first_name'], + "last_name": user_form.cleaned_data['last_name'], + "email": request.user._wrapped.email, + "account_type": "community_account", + "inquiry_type": "Membership", + "organization_name": form.cleaned_data['community_name'], + } + + mutable_post_data.update(subscription_data) + subscription_form = SubscriptionForm(mutable_post_data) # If in test site, approve immediately, skip confirmation step - if dev_prod_or_local(request.get_host()) == 'SANDBOX': + if env == 'SANDBOX': data.is_approved = True data.save() @@ -132,24 +150,10 @@ def create_community(request): affiliation.communities.add(data) affiliation.save() return redirect('dashboard') - else: - data.save() - - # Add to user affiliations - affiliation = UserAffiliation.objects.prefetch_related('communities').get(user=request.user) - affiliation.communities.add(data) - affiliation.save() - - # Adds activity to Hub Activity - HubActivity.objects.create( - action_user_id=request.user.id, - action_type="New Community", - community_id=data.id, - action_account_type='community' - ) - request.session['new_community_id'] = data.id + elif subscription_form.is_valid(): + handle_community_creation(request, data, subscription_form, env) return redirect('community-boundary') - return render(request, 'communities/create-community.html', {'form': form}) + return render(request, 'communities/create-community.html', {'form': form, 'user_form': user_form}) @has_new_community_id @@ -171,29 +175,7 @@ def upload_boundary_file(request): context = { 'community_id': community.id, } - return render(request, 'communities/upload-boundary-file.html', context) - - -# Confirm Community -@has_new_community_id -@login_required(login_url='login') -def confirm_community(request): - community = Community.objects.select_related('community_creator').get( - id=request.session.get('new_community_id') - ) - - form = ConfirmCommunityForm(request.POST or None, request.FILES, instance=community) - if request.method == "POST": - if form.is_valid(): - data = form.save(commit=False) - data.save() - send_hub_admins_account_creation_email(request, data) - - # remove new_community_id from session to prevent - # future access with this particular new_community_id - del request.session['new_community_id'] - return redirect('dashboard') - return render(request, 'accounts/confirm-account.html', {'form': form, 'community': community,}) + return render(request, 'boundary/upload-boundary-file.html', context) def public_community_view(request, pk): @@ -308,7 +290,7 @@ def update_community(request, pk): 'update_form': update_form, 'member_role': member_role, } - return render(request, 'communities/update-community.html', context) + return render(request, 'account_settings_pages/_update-account.html', context) # Members @login_required(login_url='login') @@ -1028,7 +1010,7 @@ def project_actions(request, pk, project_uuid): if not member_role or not request.user.is_authenticated or not project.can_user_access(request.user): return redirect('view-project', project_uuid) else: - notices = Notice.objects.filter(project=project, archived=False) + notices = Notice.objects.filter(project=project, archived=False).exclude(notice_type='open_to_collaborate') creator = ProjectCreator.objects.get(project=project) current_status = ProjectStatus.objects.filter(project=project, community=community).first() statuses = ProjectStatus.objects.filter(project=project) @@ -1246,7 +1228,8 @@ def apply_labels(request, pk, project_uuid): project_id=project.id ) - return redirect('apply-labels', community.id, project.unique_id) + # return redirect('community-project-actions', community.id, project.unique_id) + return HttpResponseRedirect(f"{reverse('community-project-actions', args=[community.id, project.unique_id])}#labels") context = { 'member_role': member_role, @@ -1265,19 +1248,39 @@ def apply_labels(request, pk, project_uuid): def connections(request, pk): community = get_community(pk) member_role = check_member_role(request.user, community) - communities = Community.objects.none() - institution_ids = community.contributing_communities.exclude(institutions__id=None).values_list('institutions__id', flat=True) - researcher_ids = community.contributing_communities.exclude(researchers__id=None).values_list('researchers__id', flat=True) + # Institution contributors + institution_ids = community.contributing_communities.exclude( + institutions__id=None + ).values_list('institutions__id', flat=True) + institutions = ( + Institution.objects.select_related('institution_creator') + .prefetch_related('admins', 'editors', 'viewers') + .filter(id__in=institution_ids) + ) - institutions = Institution.objects.select_related('institution_creator').prefetch_related('admins', 'editors', 'viewers').filter(id__in=institution_ids) - researchers = Researcher.objects.select_related('user').filter(id__in=researcher_ids) + # Researcher contributors + researcher_ids = community.contributing_communities.exclude( + researchers__id=None + ).values_list("researchers__id", flat=True) + researchers = Researcher.objects.select_related("user").filter( + id__in=researcher_ids + ) - project_ids = community.contributing_communities.values_list('project__unique_id', flat=True) - contributors = ProjectContributors.objects.filter(project__unique_id__in=project_ids) - for c in contributors: - communities = c.communities.select_related('community_creator').prefetch_related('admins', 'editors', 'viewers').exclude(id=community.id) + # Community contributors + project_ids = community.contributing_communities.values_list( + "project__unique_id", flat=True + ) + contributors = ProjectContributors.objects.filter( + project__unique_id__in=project_ids + ).values_list("communities__id", flat=True) + communities = ( + Community.objects.select_related("community_creator") + .prefetch_related("admins", "editors", "viewers") + .filter(id__in=contributors) + .exclude(id=community.id) + ) context = { 'member_role': member_role, @@ -1288,6 +1291,131 @@ def connections(request, pk): } return render(request, 'communities/connections.html', context) + +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) +def connect_service_provider(request, pk): + try: + community = get_community(pk) + member_role = check_member_role(request.user, community) + if request.method == "GET": + service_providers = get_certified_service_providers(request) + connected_service_providers_ids = ServiceProviderConnections.objects.filter( + communities=community + ).values_list('service_provider', flat=True) + connected_service_providers = service_providers.filter(id__in=connected_service_providers_ids) + other_service_providers = service_providers.exclude(id__in=connected_service_providers_ids) + + elif request.method == "POST": + if "connectServiceProvider" in request.POST: + if community.is_approved: + service_provider_id = request.POST.get('connectServiceProvider') + connection_reference_id = f"{service_provider_id}:{community.id}_c" + + if ServiceProviderConnections.objects.filter( + service_provider=service_provider_id).exists(): + # Connect community to existing Service Provider connection + sp_connection = ServiceProviderConnections.objects.get( + service_provider=service_provider_id + ) + sp_connection.communities.add(community) + sp_connection.save() + else: + # Create new Service Provider Connection and add community + service_provider = ServiceProvider.objects.get(id=service_provider_id) + sp_connection = ServiceProviderConnections.objects.create( + service_provider = service_provider + ) + sp_connection.communities.add(community) + sp_connection.save() + + # Delete instances of disconnect Notifications + delete_action_notification(connection_reference_id) + + # Send notification of connection to Service Provider + target_org = sp_connection.service_provider + title = f"{community.community_name} has connected to {target_org.name}" + send_simple_action_notification( + None, target_org, title, "Connections", connection_reference_id + ) + + else: + messages.add_message( + request, messages.ERROR, + 'Your account must be confirmed to connect to Service Providers.' + ) + + elif "disconnectServiceProvider" in request.POST: + service_provider_id = request.POST.get('disconnectServiceProvider') + connection_reference_id = f"{service_provider_id}:{community.id}_c" + + sp_connection = ServiceProviderConnections.objects.get( + service_provider=service_provider_id + ) + sp_connection.communities.remove(community) + sp_connection.save() + + # Delete instances of the connection notification + delete_action_notification(connection_reference_id) + + # Send notification of disconneciton to Service Provider + target_org = sp_connection.service_provider + title = f"{community.community_name} has been disconnected from " \ + f"{target_org.name}" + send_simple_action_notification( + None, target_org, title, "Connections", connection_reference_id + ) + + return redirect("community-connect-service-provider", community.id) + + context = { + 'member_role': member_role, + 'community': community, + 'other_service_providers': other_service_providers, + 'connected_service_providers': connected_service_providers, + } + return render(request, 'account_settings_pages/_connect-service-provider.html', context) + except: + raise Http404() + + +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) +def account_preferences(request, pk): + try: + community = get_community(pk) + member_role = check_member_role(request.user, community) + + if request.method == "POST": + + # Set Show/Hide account in Service Provider connections + if request.POST.get('show_sp_connection') == 'on': + community.show_sp_connection = True + + elif request.POST.get('show_sp_connection') is None: + community.show_sp_connection = False + + # Set project privacy settings for Service Provider connections + community.sp_privacy = request.POST.get('sp_privacy') + + community.save() + + messages.add_message( + request, messages.SUCCESS, 'Your preferences have been updated!' + ) + + return redirect("preferences-community", community.id) + + context = { + 'member_role': member_role, + 'community': community, + } + return render(request, 'account_settings_pages/_preferences.html', context) + + except: + raise Http404() + + # show community Labels in a PDF @login_required(login_url='login') @member_required(roles=['admin', 'editor', 'viewer']) @@ -1334,11 +1462,11 @@ def update_community_boundary(request, pk): member_role = check_member_role(request.user, community) context = { 'community': community, - 'main_area': 'boundary', + # 'main_area': 'boundary', 'member_role': member_role, 'set_boundary_url': reverse('update-community-boundary-data', kwargs={'pk': community.id}) } - return render(request, 'communities/update-community.html', context) + return render(request, 'account_settings_pages/_community-boundary.html', context) @login_required(login_url='login') @@ -1346,24 +1474,10 @@ def update_community_boundary(request, pk): def update_community_boundary_data(request, pk): community = get_community(pk) data = json.loads(request.body) - - name = data.get('name') - source = data.get('source') - boundary = data.get('boundary') - share_boundary_publicly = data.get('share_boundary_publicly', False) - - if name: - community.name_of_boundary = name - - if source: - community.source_of_boundary = source - - if 'share_boundary_publicly' in data: - community.share_boundary_publicly = share_boundary_publicly - - if boundary: - community.create_or_update_boundary(boundary) - + community.name_of_boundary = data.get('name') + community.source_of_boundary = data.get('source') + boundary_data = data.get('boundary') + community.create_or_update_boundary(boundary_data) community.save() return HttpResponse(status=204) @@ -1377,3 +1491,59 @@ def reset_community_boundary(request, pk): community.create_or_update_boundary([]) community.save() return HttpResponse(status=204) + +# Create API Key +@login_required(login_url="login") +@member_required(roles=["admin"]) +def api_keys(request, pk): + community = get_community(pk) + member_role = check_member_role(request.user, community) + + try: + if request.method == 'GET': + form = APIKeyGeneratorForm(request.GET or None) + account_keys = AccountAPIKey.objects.filter(community=community).exclude( + Q(expiry_date__lt=timezone.now()) | Q(revoked=True) + ).values_list("prefix", "name", "encrypted_key") + + elif request.method == "POST": + if "generate_api_key" in request.POST: + form = APIKeyGeneratorForm(request.POST) + + if community.is_approved: + if form.is_valid(): + api_key, key = AccountAPIKey.objects.create_key( + name = form.cleaned_data["name"], + community_id = community.id + ) + prefix = key.split(".")[0] + encrypted_key = urlsafe_base64_encode(force_bytes(key)) + AccountAPIKey.objects.filter(prefix=prefix).update(encrypted_key=encrypted_key) + else: + messages.add_message(request, messages.ERROR, 'Please enter a valid API Key name.') + return redirect("community-api-key", community.id) + + else: + messages.add_message(request, messages.ERROR, 'Your community is not yet confirmed. ' + 'Your account must be confirmed to create API Keys.') + return redirect("community-api-key", community.id) + + return redirect("community-api-key", community.id) + + elif "delete_api_key" in request.POST: + prefix = request.POST['delete_api_key'] + api_key = AccountAPIKey.objects.filter(prefix=prefix) + api_key.delete() + messages.add_message(request, messages.SUCCESS, 'API Key deleted.') + + return redirect("community-api-key", community.id) + + context = { + "community" : community, + "form" : form, + "account_keys" : account_keys, + "member_role" : member_role + } + return render(request, 'account_settings_pages/_api-keys.html', context) + except: + raise Http404() \ No newline at end of file diff --git a/env.sh-example b/env.sh-example index e018fa99e..cd5eb9e92 100644 --- a/env.sh-example +++ b/env.sh-example @@ -11,6 +11,9 @@ export SUPPORT_EMAIL= export GCS_BUCKET= +export SF_VALID_USER_IDS= +export SALES_FORCE_BASE_URL= + export RECAPTCHA_SECRET_KEY= export DB_NAME= @@ -36,3 +39,6 @@ export DBBACKUP_ACCESS_KEY= export DBBACKUP_SECRET_KEY= export DBBACKUP_BUCKET_NAME= export DBBACKUP_ENDPOINT_URL= + +export SALES_FORCE_CLIENT_ID= +export SALES_FORCE_SECRET_ID= \ No newline at end of file diff --git a/helpers/emails.py b/helpers/emails.py index ddd54dbe4..43925cda6 100644 --- a/helpers/emails.py +++ b/helpers/emails.py @@ -1,6 +1,7 @@ from communities.models import Community from institutions.models import Institution from researchers.models import Researcher +from serviceproviders.models import ServiceProvider from django.contrib.auth.models import User from helpers.models import LabelNote from notifications.models import ActionNotification @@ -82,6 +83,18 @@ def send_simple_email(email, subject, template): "html": template} ) +# Send error info +def send_subscription_fail_email(request, subject, template): + if dev_prod_or_local(request.get_host()) == 'PROD': + return requests.post( + settings.MAILGUN_BASE_URL, + auth=("api", settings.MAILGUN_API_KEY), + data={"from": "Local Contexts Hub ", + "to": "support@localcontexts.org", + "subject": subject, + "html": template} + ) + # Send email with attachment def send_email_with_attachment(file, to_email, subject, template): return requests.post( @@ -196,18 +209,34 @@ def send_email(email, subject, template, attachment=None): def get_email_and_template(): if isinstance(data, Community): subject = f'New Community Account: {data.community_name}' - template = render_to_string('snippets/emails/internal/community-application.html', {'data': data}) + template = render_to_string( + 'snippets/emails/internal/community-application.html', + {'data': data} + ) return subject, template, data.support_document elif isinstance(data, Institution): subject_prefix = "New Institution Account" subject_suffix = "(non-ROR)" if not data.is_ror else "" subject = f'{subject_prefix}: {data.institution_name} {subject_suffix}' - template = render_to_string('snippets/emails/internal/institution-application.html', {'data': data}) + template = render_to_string( + 'snippets/emails/internal/institution-application.html', + {'data': data} + ) return subject, template, None elif isinstance(data, Researcher): name = get_users_name(data.user) subject = f'New Researcher Account: {name}' - template = render_to_string('snippets/emails/internal/researcher-account-connection.html', { 'researcher': data }) + template = render_to_string( + 'snippets/emails/internal/researcher-account-connection.html', + { 'researcher': data } + ) + return subject, template, None + elif isinstance(data, ServiceProvider): + subject = f'New Service Provider Account: {data.name}' + template = render_to_string( + 'snippets/emails/internal/service-provider-application.html', + {'data': data} + ) return subject, template, None else: return None, None, None @@ -267,6 +296,32 @@ def send_institution_email(request, institution): "Local Contexts Hub " ) +def send_service_provider_email(request, service_provider): + if dev_prod_or_local(request.get_host()) == 'PROD': + name = get_users_name(request.user) + subject = f'Service Provider Account: {service_provider.name}' + data = { + 'account_creator_name': name, + 'service_provider_name': service_provider.name + } + + cc_emails = [ + settings.SUPPORT_EMAIL, + settings.CC_EMAIL_LH + ] + if service_provider.contact_email: + cc_emails.append(service_provider.contact_email) + + # Send email to institution + send_mailgun_template_email( + request.user.email, + subject, + 'new_service_provider_account', + data, + cc_emails, + "Local Contexts Hub " + ) + """ EMAILS FOR ACCOUNTS APP """ @@ -381,6 +436,8 @@ def send_contact_email(request, to_email, from_name, from_email, message, accoun account_name = account.community_name if isinstance(account, Researcher): account_name = 'your researcher account' + if isinstance(account, ServiceProvider): + account_name = account.name data = { "from_name": from_name, "message": message, "account_name": account_name } @@ -410,6 +467,8 @@ def send_member_invite_email(request, data, account): org_name = account.institution_name if isinstance(account, Community): org_name = account.community_name + if isinstance(account, ServiceProvider): + org_name = account.name if data.role == 'admin': role = 'Administrator' @@ -456,7 +515,12 @@ def send_email_notice_placed(request, project, community, account): 'community_name': community.community_name, 'login_url': login_url } - send_mailgun_template_email(community.community_creator.email, subject, 'notice_placed', data) + send_mailgun_template_email( + community.community_creator.email, + subject, + 'notice_placed', + data + ) #Project status has been changed def send_action_notification_project_status(request, project, communities): @@ -468,7 +532,12 @@ def send_action_notification_project_status(request, project, communities): for community in communities: community = get_community(community) title = f"{request.user} has edited a Project: '{project}'." - ActionNotification.objects.create(community=community, sender=request.user, notification_type="Projects", title=title, reference_id=project.unique_id) + ActionNotification.objects.create( + community=community, + sender=request.user, + notification_type="Projects", + title=title, reference_id=project.unique_id + ) """ EMAILS FOR COMMUNITY APP @@ -486,7 +555,12 @@ def send_email_labels_applied(request, project, community): 'project_title': project.title, 'login_url': login_url } - send_mailgun_template_email(project.project_creator.email, subject, 'labels_applied', data) + send_mailgun_template_email( + project.project_creator.email, + subject, + 'labels_applied', + data + ) # Label has been approved or not @@ -519,7 +593,7 @@ def send_email_label_approved(request, label, note_id): } send_mailgun_template_email(label.created_by.email, subject, 'label_approved', data) -# You are now a member of institution/community email +# You are now a member of institution/community/service provider email def send_membership_email(request, account, receiver, role): environment = dev_prod_or_local(request.get_host()) @@ -537,6 +611,7 @@ def send_membership_email(request, account, receiver, role): community = False institution = False + service_provider = False if isinstance(account, Community): subject = f'You are now a member of {account.community_name}' @@ -546,13 +621,18 @@ def send_membership_email(request, account, receiver, role): subject = f'You are now a member of {account.institution_name}' account_name = account.institution_name institution = True + if isinstance(account, ServiceProvider): + subject = f'You are now a member of {account.name}' + account_name = account.name + service_provider = True data = { 'role_str': role_str, 'account_name': account_name, 'login_url': login_url, 'community': community, - 'institution': institution + 'institution': institution, + 'service_provider': service_provider } send_mailgun_template_email(receiver.email, subject, 'member_info', data) @@ -665,9 +745,23 @@ def send_project_person_email(request, to_email, proj_id, account): def send_email_verification(request, old_email, new_email, verification_url): subject = 'Email Verification Link For Your Local Contexts Hub Profile' - data = {'user':request.user.username, 'new_email':new_email, 'old_email':old_email, 'verification_url':verification_url} + data = { + 'user': request.user.username, + 'new_email': new_email, + 'old_email': old_email, + 'verification_url': verification_url + } send_mailgun_template_email(new_email, subject, 'verify_email_update', data) old_subject = 'Change of Email For Your Local Contexts Hub Profile' - old_email_data = {'user':request.user.username, 'old_email':old_email, 'new_email':new_email} - send_mailgun_template_email(old_email, old_subject,'notify_email_on_email_update', old_email_data) + old_email_data = { + 'user': request.user.username, + 'old_email': old_email, + 'new_email': new_email + } + send_mailgun_template_email( + old_email, + old_subject, + 'notify_email_on_email_update', + old_email_data + ) diff --git a/helpers/exceptions.py b/helpers/exceptions.py index 8dbcecf5f..991ffda31 100644 --- a/helpers/exceptions.py +++ b/helpers/exceptions.py @@ -1,2 +1,9 @@ +from django.core.exceptions import PermissionDenied + + class UnconfirmedAccountException(Exception): pass + + +class UnsubscribedAccountException(PermissionDenied): + pass diff --git a/helpers/migrations/0061_remove_opentocollaboratenoticeurl_helpers_ope_institu_f0c7a8_idx_and_more.py b/helpers/migrations/0061_remove_opentocollaboratenoticeurl_helpers_ope_institu_f0c7a8_idx_and_more.py new file mode 100644 index 000000000..fa527c1c1 --- /dev/null +++ b/helpers/migrations/0061_remove_opentocollaboratenoticeurl_helpers_ope_institu_f0c7a8_idx_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 5.0.6 on 2024-06-26 20:53 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('helpers', '0060_auto_20231220_1706'), + ('institutions', '0035_remove_institution_is_submitted'), + ('researchers', '0039_remove_researcher_is_submitted'), + ('serviceproviders', '0001_initial'), + ] + + operations = [ + migrations.RemoveIndex( + model_name='opentocollaboratenoticeurl', + name='helpers_ope_institu_f0c7a8_idx', + ), + migrations.AddField( + model_name='opentocollaboratenoticeurl', + name='service_provider', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='otc_service_provider_url', to='serviceproviders.serviceprovider'), + ), + migrations.AddIndex( + model_name='opentocollaboratenoticeurl', + index=models.Index(fields=['institution', 'researcher', 'service_provider'], name='helpers_ope_institu_d9d192_idx'), + ), + ] diff --git a/helpers/migrations/0062_noticedownloadtracker_service_provider.py b/helpers/migrations/0062_noticedownloadtracker_service_provider.py new file mode 100644 index 000000000..1415f6ea3 --- /dev/null +++ b/helpers/migrations/0062_noticedownloadtracker_service_provider.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.6 on 2024-06-26 20:55 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('helpers', '0061_remove_opentocollaboratenoticeurl_helpers_ope_institu_f0c7a8_idx_and_more'), + ('serviceproviders', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='noticedownloadtracker', + name='service_provider', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='serviceproviders.serviceprovider'), + ), + ] diff --git a/helpers/migrations/0063_hubactivity_service_provider_id.py b/helpers/migrations/0063_hubactivity_service_provider_id.py new file mode 100644 index 000000000..aee0624dd --- /dev/null +++ b/helpers/migrations/0063_hubactivity_service_provider_id.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-07-09 20:43 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('helpers', '0062_noticedownloadtracker_service_provider'), + ] + + operations = [ + migrations.AddField( + model_name='hubactivity', + name='service_provider_id', + field=models.IntegerField(blank=True, null=True), + ), + ] diff --git a/helpers/migrations/0064_alter_hubactivity_action_type.py b/helpers/migrations/0064_alter_hubactivity_action_type.py new file mode 100644 index 000000000..f2a4621dd --- /dev/null +++ b/helpers/migrations/0064_alter_hubactivity_action_type.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-07-24 16:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('helpers', '0063_hubactivity_service_provider_id'), + ] + + operations = [ + migrations.AlterField( + model_name='hubactivity', + name='action_type', + field=models.CharField(choices=[('New Member Added', 'New Member Added'), ('New User', 'New User'), ('New Researcher', 'New Researcher'), ('New Community', 'New Community'), ('New Institution', 'New Institution'), ('New Service Provider', 'New Service Provider'), ('Project Edited', 'Project Edited'), ('Project Created', 'Project Created'), ('Community Notified', 'Community Notified'), ('Label(s) Applied', 'Label(s) Applied'), ('Disclosure Notice(s) Added', 'Disclosure Notice(s) Added'), ('Engagement Notice Added', 'Engagement Notice Added')], max_length=30, null=True), + ), + ] diff --git a/helpers/models.py b/helpers/models.py index 4d7bd8eda..5211b2532 100644 --- a/helpers/models.py +++ b/helpers/models.py @@ -8,12 +8,14 @@ from communities.models import Community from researchers.models import Researcher from institutions.models import Institution +from serviceproviders.models import ServiceProvider class Notice(models.Model): TYPES = ( ('biocultural', 'biocultural'), ('traditional_knowledge', 'traditional_knowledge'), ('attribution_incomplete', 'attribution_incomplete'), + ('open_to_collaborate', 'open_to_collaborate'), ) project = models.ForeignKey('projects.Project', null=True, on_delete=models.CASCADE, related_name="project_notice", db_index=True) notice_type = models.CharField(max_length=50, null=True, choices=TYPES) @@ -100,6 +102,7 @@ class Meta: class OpenToCollaborateNoticeURL(models.Model): institution = models.ForeignKey(Institution, null=True, on_delete=models.CASCADE, blank=True, db_index=True, related_name="otc_institution_url") researcher = models.ForeignKey(Researcher, null=True, on_delete=models.CASCADE, blank=True, db_index=True, related_name="otc_researcher_url") + service_provider = models.ForeignKey(ServiceProvider, null=True, on_delete=models.CASCADE, blank=True, db_index=True, related_name="otc_service_provider_url") name = models.CharField('Name of Website', max_length=250, null=True, blank=True) url = models.URLField('Link', null=True, unique=True) added = models.DateTimeField(auto_now_add=True, null=True) @@ -108,7 +111,7 @@ def __str__(self): return str(self.name) class Meta: - indexes = [models.Index(fields=['institution', 'researcher'])] + indexes = [models.Index(fields=['institution', 'researcher', 'service_provider'])] verbose_name = 'Open To Collaborate Notice URL' verbose_name_plural = 'Open To Collaborate Notice URLs' @@ -253,6 +256,7 @@ class Meta: class NoticeDownloadTracker(models.Model): institution = models.ForeignKey(Institution, null=True, on_delete=models.CASCADE, blank=True, db_index=True) researcher = models.ForeignKey(Researcher, null=True, on_delete=models.CASCADE, blank=True, db_index=True) + service_provider = models.ForeignKey(ServiceProvider, null=True, on_delete=models.CASCADE, blank=True, db_index=True) user = models.ForeignKey(User, on_delete=models.CASCADE, null=True, related_name="download_user", blank=True) collections_care_notices = models.BooleanField(default=False, null=True) open_to_collaborate_notice = models.BooleanField(default=False, null=True) @@ -285,6 +289,7 @@ class HubActivity(models.Model): ('New Researcher', 'New Researcher'), ('New Community', 'New Community'), ('New Institution', 'New Institution'), + ('New Service Provider', 'New Service Provider'), ('Project Edited', 'Project Edited'), ('Project Created', 'Project Created'), ('Community Notified', 'Community Notified'), @@ -297,6 +302,7 @@ class HubActivity(models.Model): action_account_type = models.CharField(max_length=250, null=True, blank=True) community_id = models.IntegerField(null=True, blank=True) institution_id = models.IntegerField(null=True, blank=True) + service_provider_id = models.IntegerField(null=True, blank=True) project_id = models.IntegerField(null=True, blank=True) action_type = models.CharField(max_length=30, null=True, choices=TYPES) date = models.DateTimeField(auto_now_add=True, null=True) diff --git a/helpers/urls.py b/helpers/urls.py index b3eba68ef..b934e6654 100644 --- a/helpers/urls.py +++ b/helpers/urls.py @@ -4,6 +4,7 @@ urlpatterns = [ path('download/open-to-collaborate-notice/researcher///', views.download_open_collaborate_notice, name="download-open-to-collaborate-notice-researcher"), path('download/open-to-collaborate-notice/institution///', views.download_open_collaborate_notice, name="download-open-to-collaborate-notice-institution"), + path('download/open-to-collaborate-notice/service-provider///', views.download_open_collaborate_notice, name="download-open-to-collaborate-notice-service-provider"), path('download/collections-care-notices///', views.download_collections_care_notices, name="download-collections-care-notices"), path('invite/delete//', views.delete_member_invite, name="delete-member-invite"), path('download/community/support-letter/', views.download_community_support_letter, name="download-community-support-letter"), diff --git a/helpers/utils.py b/helpers/utils.py index 130c746eb..3b7bae9f9 100644 --- a/helpers/utils.py +++ b/helpers/utils.py @@ -2,58 +2,92 @@ import urllib import zipfile from typing import Union +from django.contrib.postgres.search import SearchQuery, SearchVector, SearchRank import requests from django.conf import settings -from django.template.loader import get_template +from django.template.loader import get_template ,render_to_string from io import BytesIO -from accounts.models import UserAffiliation +from accounts.models import UserAffiliation, Subscription from tklabels.models import TKLabel from bclabels.models import BCLabel -from helpers.models import LabelTranslation, LabelVersion, LabelTranslationVersion, HubActivity +from helpers.models import ( + LabelTranslation, + LabelVersion, + LabelTranslationVersion, + HubActivity, +) +from django.db.models import Q from xhtml2pdf import pisa from communities.models import Community, JoinRequest, InviteMember, Boundary from institutions.models import Institution from researchers.models import Researcher +from serviceproviders.models import ServiceProvider +from .exceptions import UnsubscribedAccountException from .models import Notice from notifications.models import * -from accounts.utils import get_users_name +from accounts.forms import UserCreateProfileForm, SubscriptionForm + +from accounts.utils import get_users_name, confirm_subscription from notifications.utils import send_user_notification_member_invite_accept -from helpers.emails import send_membership_email +from helpers.emails import ( + send_membership_email, send_subscription_fail_email, + send_hub_admins_account_creation_email, send_service_provider_email +) from django.contrib.staticfiles import finders from django.shortcuts import get_object_or_404 +import urllib.parse +import urllib.request +from django.contrib import messages +from django.shortcuts import redirect +from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode +from django.utils.encoding import force_bytes, force_str +import traceback +import re +from django.http import HttpResponseForbidden +from django.utils import timezone + def check_member_role(user, organization): # Check for creator roles if isinstance(organization, Community) and user == organization.community_creator: - return 'admin' - if isinstance(organization, Institution) and user == organization.institution_creator: - return 'admin' + return "admin" + if ( + isinstance(organization, Institution) + and user == organization.institution_creator + ): + return "admin" + if isinstance(organization, ServiceProvider) and user == organization.account_creator: + return "admin" # Check for admin/editor/viewer roles - if organization.admins.filter(id=user.id).exists(): - return 'admin' - elif organization.editors.filter(id=user.id).exists(): - return 'editor' - elif organization.viewers.filter(id=user.id).exists(): - return 'viewer' + if isinstance(organization, ServiceProvider): + if organization.editors.filter(id=user.id).exists(): + return "editor" + else: + if organization.admins.filter(id=user.id).exists(): + return "admin" + elif organization.editors.filter(id=user.id).exists(): + return "editor" + elif organization.viewers.filter(id=user.id).exists(): + return "viewer" return False def change_member_role(org, member, current_role, new_role): role_map = { - 'admin': org.admins, - 'editor': org.editors, - 'viewer': org.viewers, + "admin": org.admins, + "editor": org.editors, + "viewer": org.viewers, } if current_role and current_role in role_map: role_map[current_role].remove(member) - + if new_role and new_role in role_map: role_map[new_role].add(member) @@ -61,34 +95,66 @@ def change_member_role(org, member, current_role, new_role): def add_user_to_role(account, role, user): - role_map = { - 'admin': account.admins, - 'editor': account.editors, - 'viewer': account.viewers, - } - role_map[role].add(user) - account.save() - + if isinstance(account, ServiceProvider) and role == "editor": + account.editors.add(user) + account.save() + else: + role_map = { + "admin": account.admins, + "editor": account.editors, + "viewer": account.viewers, + } + role_map[role].add(user) + account.save() + + +def request_possible(request, org, selected_role): + if selected_role.lower() in ('editor', 'administrator', 'admin') and isinstance(org, Institution): + try: + subscription = Subscription.objects.get(institution=org) + if subscription.users_count > 0: + subscription.users_count -= 1 + subscription.save() + return True + elif subscription.users_count == -1: + return True + else: + messages.error(request, 'The editor and admin limit for this institution has been reached. Please contact the institution and let them know to upgrade their subscription plan to add more editors and admins.') + return False + except Subscription.DoesNotExist: + messages.add_message(request, messages.ERROR, 'The subscription process of your institution is not completed yet. Please wait for the completion of subscription process.') + return False + return True + def accept_member_invite(request, invite_id): invite = get_object_or_404(InviteMember, id=invite_id) affiliation = get_object_or_404(UserAffiliation, user=invite.receiver) # Which organization, add to user affiliation - account = invite.community or invite.institution + account = invite.community or invite.institution or invite.service_provider + if invite.institution and not request_possible(request, account, invite.role): + return redirect("member-invitations") + if invite.community: affiliation.communities.add(account) if invite.institution: affiliation.institutions.add(account) - + if invite.service_provider: + affiliation.service_providers.add(account) + affiliation.save() - - add_user_to_role(account, invite.role, invite.receiver) # Add user to role - send_user_notification_member_invite_accept(invite) # Send UserNotifications - send_membership_email(request, account, invite.receiver, invite.role) # Send email notifications letting user know they are a member + + add_user_to_role(account, invite.role, invite.receiver) # Add user to role + send_user_notification_member_invite_accept(invite) # Send UserNotifications + send_membership_email( + request, account, invite.receiver, invite.role + ) # Send email notifications letting user know they are a member # Delete relevant user notification - UserNotification.objects.filter(to_user=invite.receiver, from_user=invite.sender, reference_id=invite.id).delete() + UserNotification.objects.filter( + to_user=invite.receiver, from_user=invite.sender, reference_id=invite.id + ).delete() def accepted_join_request(request, org, join_request_id, selected_role): @@ -98,41 +164,63 @@ def accepted_join_request(request, org, join_request_id, selected_role): if selected_role is None: pass else: + if not request_possible(request, org, selected_role): + return # Add organization to userAffiliation and delete relevant action notification affiliation = UserAffiliation.objects.get(user=join_request.user_from) if isinstance(org, Community): affiliation.communities.add(org) - if ActionNotification.objects.filter(sender=join_request.user_from, community=org, reference_id=join_request.id).exists(): - notification = ActionNotification.objects.get(sender=join_request.user_from, community=org, reference_id=join_request.id) + if ActionNotification.objects.filter( + sender=join_request.user_from, + community=org, + reference_id=join_request.id, + ).exists(): + notification = ActionNotification.objects.get( + sender=join_request.user_from, + community=org, + reference_id=join_request.id, + ) notification.delete() if isinstance(org, Institution): affiliation.institutions.add(org) - if ActionNotification.objects.filter(sender=join_request.user_from, institution=org, reference_id=join_request.id).exists(): - notification = ActionNotification.objects.get(sender=join_request.user_from, institution=org, reference_id=join_request.id) + if ActionNotification.objects.filter( + sender=join_request.user_from, + institution=org, + reference_id=join_request.id, + ).exists(): + notification = ActionNotification.objects.get( + sender=join_request.user_from, + institution=org, + reference_id=join_request.id, + ) notification.delete() # Add member to role - if selected_role == 'Administrator': + if selected_role == "Administrator": org.admins.add(join_request.user_from) - elif selected_role == 'Editor': + elif selected_role == "Editor": org.editors.add(join_request.user_from) - elif selected_role == 'Viewer': + elif selected_role == "Viewer": org.viewers.add(join_request.user_from) + messages.add_message(request, messages.SUCCESS, 'You have successfully added a new member!') # Create UserNotification sender = join_request.user_from title = f"You are now a member of {org}!" - UserNotification.objects.create(to_user=sender, title=title, notification_type="Accept") + UserNotification.objects.create( + to_user=sender, title=title, notification_type="Accept" + ) send_membership_email(request, org, sender, selected_role) # Delete join request join_request.delete() + # h/t: https://stackoverflow.com/questions/59695870/generate-multiple-pdfs-and-zip-them-for-download-all-in-a-single-view def render_to_pdf(template_src, context_dict={}): template = get_template(template_src) - html = template.render(context_dict) + html = template.render(context_dict) buffer = BytesIO() p = pisa.pisaDocument(BytesIO(html.encode("UTF-8")), buffer) pdf = buffer.getvalue() @@ -141,41 +229,46 @@ def render_to_pdf(template_src, context_dict={}): return pdf return None + def generate_zip(files): mem_zip = BytesIO() - with zipfile.ZipFile(mem_zip, mode="w",compression=zipfile.ZIP_DEFLATED) as zf: + with zipfile.ZipFile(mem_zip, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: for f in files: zf.writestr(f[0], f[1]) return mem_zip.getvalue() + def get_labels_json(): - json_data = open('./localcontexts/static/json/Labels.json') - data = json.load(json_data) #deserialize + json_data = open("./localcontexts/static/json/Labels.json") + data = json.load(json_data) # deserialize return data + def get_notice_translations(): - json_path = finders.find('json/NoticeTranslations.json') - with open(json_path, 'r', encoding="utf8") as file: + json_path = finders.find("json/NoticeTranslations.json") + with open(json_path, "r", encoding="utf8") as file: data = json.load(file) - + # Restructure the data as a nested dictionary with noticeType and language as keys notice_translations = {} for item in data: - notice_type = item['noticeType'] - language_tag = item['languageTag'] + notice_type = item["noticeType"] + language_tag = item["languageTag"] if notice_type not in notice_translations: notice_translations[notice_type] = {} notice_translations[notice_type][language_tag] = item return notice_translations + def get_notice_defaults(): - json_path = finders.find('json/Notices.json') - with open(json_path, 'r') as file: + json_path = finders.find("json/Notices.json") + with open(json_path, "r") as file: data = json.load(file) return data + # Create/Update/Delete Notices and Notice Translations def crud_notices(request, selected_notices, selected_translations, organization, project, existing_notices, has_changes): # organization: instance of institution or researcher @@ -184,37 +277,41 @@ def crud_notices(request, selected_notices, selected_translations, organization, # selected_translations: list: ['traditional_knowledge-fr', 'biocultural-es'], etc. from projects.models import ProjectActivity + name = get_users_name(request.user) def create(notice_type): if isinstance(organization, (Institution, Researcher)): notice_fields = { - 'notice_type': notice_type, - 'project': project, + "notice_type": notice_type, + "project": project, } if isinstance(organization, Institution): - notice_fields['institution'] = organization + notice_fields["institution"] = organization # Adds activity to Hub Activity HubActivity.objects.create( action_user_id=request.user.id, action_type="Disclosure Notice(s) Added", project_id=project.id, - action_account_type = 'institution', - institution_id=organization.id + action_account_type="institution", + institution_id=organization.id, ) elif isinstance(organization, Researcher): - notice_fields['researcher'] = organization + notice_fields["researcher"] = organization # Adds activity to Hub Activity HubActivity.objects.create( action_user_id=request.user.id, action_type="Disclosure Notice(s) Added", project_id=project.id, - action_account_type = 'researcher' + action_account_type="researcher", ) new_notice = Notice.objects.create(**notice_fields) - ProjectActivity.objects.create(project=project, activity=f'{new_notice.name} was applied to the Project by {name}') + ProjectActivity.objects.create( + project=project, + activity=f"{new_notice.name} was applied to the Project by {name}", + ) # Create any notice translations update_notice_translation(new_notice, selected_translations) @@ -229,7 +326,7 @@ def create_notices(existing_notice_types): create(notice_type) else: create(notice_type) - + def update_notice_translation(notice, selected_translations): selected_notice_types_langs = [value.split('-') for value in selected_translations] nonlocal has_changes @@ -247,10 +344,12 @@ def update_notice_translation(notice, selected_translations): # Check if the notice type matches the selected translation if notice.notice_type == ntype: # If translation of this type in this language does NOT exist, create it. - if not notice.notice_translations.filter(notice_type=ntype, language_tag=lang_tag).exists(): + if not notice.notice_translations.filter( + notice_type=ntype, language_tag=lang_tag + ).exists(): has_changes = True notice.save(language_tag=lang_tag) - + if existing_notices: existing_notice_types = [] for notice in existing_notices: @@ -258,8 +357,10 @@ def update_notice_translation(notice, selected_translations): if not notice.notice_type in selected_notices: # if existing notice not in selected notices, delete notice has_changes = True notice.delete() - ProjectActivity.objects.create(project=project, activity=f'{notice.name} was removed from the Project by {name}') - continue + ProjectActivity.objects.create( + project=project, + activity=f"{notice.name} was removed from the Project by {name}", + ) update_notice_translation(notice, selected_translations) create_notices(existing_notice_types) return has_changes @@ -267,11 +368,13 @@ def update_notice_translation(notice, selected_translations): create_notices(None) return has_changes + def add_remove_labels(request, project, community): from projects.models import ProjectActivity + # Get uuids of each label that was checked and add them to the project - bclabels_selected = request.POST.getlist('selected_bclabels') - tklabels_selected = request.POST.getlist('selected_tklabels') + bclabels_selected = request.POST.getlist("selected_bclabels") + tklabels_selected = request.POST.getlist("selected_tklabels") bclabels = BCLabel.objects.filter(unique_id__in=bclabels_selected) tklabels = TKLabel.objects.filter(unique_id__in=tklabels_selected) @@ -281,25 +384,43 @@ def add_remove_labels(request, project, community): # find target community labels and clear those only! if project.bc_labels.filter(community=community).exists(): - for bclabel in project.bc_labels.filter(community=community).exclude(unique_id__in=bclabels_selected): # does project have labels from this community that aren't the selected ones? - project.bc_labels.remove(bclabel) - ProjectActivity.objects.create(project=project, activity=f'{bclabel.name} Label was removed by {user} | {community.community_name}') + for bclabel in project.bc_labels.filter(community=community).exclude( + unique_id__in=bclabels_selected + ): # does project have labels from this community that aren't the selected ones? + project.bc_labels.remove(bclabel) + ProjectActivity.objects.create( + project=project, + activity=f"{bclabel.name} Label was removed by {user} | {community.community_name}", + ) if project.tk_labels.filter(community=community).exists(): - for tklabel in project.tk_labels.filter(community=community).exclude(unique_id__in=tklabels_selected): + for tklabel in project.tk_labels.filter(community=community).exclude( + unique_id__in=tklabels_selected + ): project.tk_labels.remove(tklabel) - ProjectActivity.objects.create(project=project, activity=f'{tklabel.name} Label was removed by {user} | {community.community_name}') + ProjectActivity.objects.create( + project=project, + activity=f"{tklabel.name} Label was removed by {user} | {community.community_name}", + ) for bclabel in bclabels: - if not bclabel in project.bc_labels.all(): # if label not in project labels, apply it + if ( + not bclabel in project.bc_labels.all() + ): # if label not in project labels, apply it project.bc_labels.add(bclabel) - ProjectActivity.objects.create(project=project, activity=f'{bclabel.name} Label was applied by {user} | {community.community_name}') + ProjectActivity.objects.create( + project=project, + activity=f"{bclabel.name} Label was applied by {user} | {community.community_name}", + ) for tklabel in tklabels: if not tklabel in project.tk_labels.all(): project.tk_labels.add(tklabel) - ProjectActivity.objects.create(project=project, activity=f'{tklabel.name} Label was applied by {user} | {community.community_name}') - + ProjectActivity.objects.create( + project=project, + activity=f"{tklabel.name} Label was applied by {user} | {community.community_name}", + ) + project.save() @@ -313,7 +434,9 @@ def handle_label_versions(label): translations = LabelTranslation.objects.filter(bclabel=label) # If approved version exists, set version number to 1 more than the latest - latest_version = LabelVersion.objects.filter(bclabel=label).order_by('-version').first() + latest_version = ( + LabelVersion.objects.filter(bclabel=label).order_by("-version").first() + ) if latest_version is not None: if latest_version.is_approved: @@ -329,19 +452,21 @@ def handle_label_versions(label): # Create Version for BC Label version = LabelVersion.objects.create( bclabel=label, - version=version_num, - created_by=label.created_by, + version=version_num, + created_by=label.created_by, is_approved=True, approved_by=label.approved_by, version_text=label.label_text, - created=label.created + created=label.created, ) if isinstance(label, TKLabel): translations = LabelTranslation.objects.filter(tklabel=label) # If approved version exists, set version number to 1 more than the latest - latest_version = LabelVersion.objects.filter(tklabel=label).order_by('-version').first() + latest_version = ( + LabelVersion.objects.filter(tklabel=label).order_by("-version").first() + ) if latest_version is not None: if latest_version.is_approved: @@ -357,12 +482,12 @@ def handle_label_versions(label): # Create Version for TK Label version = LabelVersion.objects.create( tklabel=label, - version=version_num, - created_by=label.created_by, + version=version_num, + created_by=label.created_by, is_approved=True, approved_by=label.approved_by, version_text=label.label_text, - created=label.created + created=label.created, ) # Create version translations @@ -370,22 +495,23 @@ def handle_label_versions(label): LabelTranslationVersion.objects.create( version_instance=version, translated_name=t.translated_name, - language=t.language, + language=t.language, language_tag=t.language_tag, translated_text=t.translated_text, - created=version.created + created=version.created, ) + def discoverable_project_view(project, user): project_contributors = project.project_contributors creator_account = project.project_creator_project.first() is_created_by = creator_account.which_account_type_created() notified = project.project_notified.first() - discoverable = 'partial' + discoverable = "partial" if not user.is_authenticated: - discoverable = 'partial' + discoverable = "partial" elif creator_account.is_user_in_creator_account(user, is_created_by): discoverable = True elif project_contributors.is_user_contributor(user): @@ -397,28 +523,30 @@ def discoverable_project_view(project, user): return discoverable + def get_alt_text(data, bclabels, tklabels): for label in bclabels: - item = next((x for x in data['bcLabels'] if x['labelName'] == label.name), None) + item = next((x for x in data["bcLabels"] if x["labelName"] == label.name), None) if item is not None: - label.alt_text = item['labelAlternateText'] + label.alt_text = item["labelAlternateText"] else: - label.alt_text = "BC label icon" - + label.alt_text = "BC label icon" + for label in tklabels: - item = next((x for x in data['tkLabels'] if x['labelName'] == label.name), None) + item = next((x for x in data["tkLabels"] if x["labelName"] == label.name), None) if item is not None: - label.alt_text = item['labelAlternateText'] + label.alt_text = item["labelAlternateText"] else: - label.alt_text = "TK label icon" - - return bclabels, tklabels + label.alt_text = "TK label icon" + + return bclabels, tklabels + def validate_email(email): url = f"{settings.MAILGUN_V4_BASE_URL}/address/validate" auth = ("api", settings.MAILGUN_API_KEY) params = {"address": email} - + response = requests.get(url, auth=auth, params=params) if response.status_code == 200: data = response.json() @@ -430,29 +558,36 @@ def validate_email(email): print("Request failed with status code:", response.status_code) return False + +def extract_error_line(traceback_info): + pattern = r'File "(.*?)", line (\d+), in (.*?)\n\s+(.*)' + matches = re.findall(pattern, traceback_info) + + if matches: + return matches[0] + else: + return None + + def validate_recaptcha(request_object): - recaptcha_response = request_object.POST.get('g-recaptcha-response') - url = 'https://www.google.com/recaptcha/api/siteverify' + recaptcha_response = request_object.POST.get("g-recaptcha-response") + url = "https://www.google.com/recaptcha/api/siteverify" values = { - 'secret': settings.GOOGLE_RECAPTCHA_SECRET_KEY, - 'response': recaptcha_response + "secret": settings.GOOGLE_RECAPTCHA_SECRET_KEY, + "response": recaptcha_response, } data = urllib.parse.urlencode(values).encode() req = urllib.request.Request(url, data=data) response = urllib.request.urlopen(req) result = json.loads(response.read().decode()) - return result.get('success', False) and result.get('score', 0.0) >= settings.RECAPTCHA_REQUIRED_SCORE - - -def create_or_update_boundary( - post_data: dict, - entity: Union['Community', 'Project'] -): - share_boundary_publicly = post_data.get('share-boundary-publicly') + return ( + result.get("success", False) + and result.get("score", 0.0) >= settings.RECAPTCHA_REQUIRED_SCORE + ) - if share_boundary_publicly: - entity.share_boundary_publicly = share_boundary_publicly == 'on' +def create_or_update_boundary(post_data: dict, entity: Union['Community', 'Project']): + entity.share_boundary_publicly = post_data.get('share-boundary-publicly') == 'on' raw_boundary_payload = post_data.get('boundary-payload') if raw_boundary_payload in ['', '{}', None]: @@ -493,3 +628,236 @@ def retrieve_native_land_all_slug_data() -> dict: ) response = requests.get(url) return response.json() + + +def create_salesforce_account_or_lead(request, hubId="", data="", isbusiness=True): + salesforce_token_url = f"{settings.SALES_FORCE_BASE_URL}/oauth2/token" + salesforce_token_params = { + "grant_type": "client_credentials", + "client_id": settings.SALES_FORCE_CLIENT_ID, + "client_secret": settings.SALES_FORCE_SECRET_ID, + } + salesforce_token_data = urllib.parse.urlencode(salesforce_token_params).encode() + salesforce_token_req = urllib.request.Request( + salesforce_token_url, data=salesforce_token_data + ) + try: + salesforce_token_response = urllib.request.urlopen(salesforce_token_req) + salesforce_token_result = json.loads(salesforce_token_response.read().decode()) + access_token = salesforce_token_result["access_token"] + + lead_data = { + "hubId": hubId, + "companyName": data["organization_name"], + "email": data["email"], + "firstname": data["first_name"], + "lastName": data["last_name"], + "oppName": data["organization_name"], + "inquiryType": data["inquiry_type"], + "isBusinessTrue": isbusiness, + } + + # Make API call to create lead in Salesforce + create_lead_url = f"{settings.SALES_FORCE_BASE_URL}/apexrest/createAccountOrLeadWithRelatedContactAndOpportunity" + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + create_lead_req = urllib.request.Request( + create_lead_url, data=json.dumps(lead_data).encode(), headers=headers + ) + try: + create_lead_response = urllib.request.urlopen(create_lead_req) + return True + + except urllib.error.HTTPError as e: + reason= "Failed to create lead in Salesforce." + subject= "Subscription failed" + traceback_info = traceback.format_exc() + error = extract_error_line(traceback_info) + error_file, error_line, error_syntax = error[0], error[1], error[3] + context= { + "data" : data, + "request": request, + "reason": reason, + "error_file": error_file, + "error_line": error_line, + "error_syntax": error_syntax, + } + template = render_to_string('snippets/emails/internal/subscription-failed-info.html', context) + send_subscription_fail_email(request, subject, template) + raise Exception(reason) + except urllib.error.HTTPError as e: + reason= "Unable to get token of access from Salesforce" + subject= "Subscription failed" + traceback_info = traceback.format_exc() + error = extract_error_line(traceback_info) + error_file, error_line, error_syntax = error[0], error[1], error[3] + context= { + "data" : data, + "request": request, + "reason": reason, + "error_file": error_file, + "error_line": error_line, + "error_syntax": error_syntax, + } + template = render_to_string('snippets/emails/internal/subscription-failed-info.html', context) + send_subscription_fail_email(request, subject, template) + raise Exception(reason) + + +def validate_is_subscribed( + account: Union[Researcher, Institution, ServiceProvider], + bypass_validation: bool = False +): + if bypass_validation: + return + + if account.is_subscribed or account.is_certified: + return + message = 'Account Is Not Subscribed' + raise UnsubscribedAccountException(message) + +def encrypt_api_key(key): + encrypted_key = urlsafe_base64_encode(force_bytes(key)) + return encrypted_key + +def decrypt_api_key(key): + api_key = force_str(urlsafe_base64_decode(key)) + return api_key + +def form_initiation(request): + fields_to_update = { + "first_name": request.user._wrapped.first_name, + "last_name": request.user._wrapped.last_name, + } + user_form = UserCreateProfileForm(request.POST or None, initial=fields_to_update) + + for field, value in fields_to_update.items(): + if value: + user_form.fields[field].widget.attrs.update({"class": "w-100 readonly-input"}) + + return user_form + +def check_subscription(request, subscriber_type, id): + subscriber_field_mapping = { + 'institution': 'institution_id', + 'researcher': 'researcher_id', + 'community': 'community_id', + 'service_provider': 'service_provider_id' + } + + if subscriber_type not in subscriber_field_mapping: + raise ValueError("Invalid subscriber type provided.") + + subscriber_field = subscriber_field_mapping[subscriber_type] + + try: + subscription = Subscription.objects.get(**{subscriber_field: id}) + except Subscription.DoesNotExist: + messages.add_message(request, messages.ERROR, 'The subscription process of your account is not completed yet. Please wait for the completion of subscription process.') + return HttpResponseForbidden('Forbidden: Subscription process isnt completed. ') + + if subscription.project_count == 0: + messages.add_message(request, messages.ERROR, 'Your account has reached its Project limit. ' + 'Please upgrade your subscription plan to create more Projects.') + return HttpResponseForbidden('Forbidden: Project limit of account is reached. ') + +def handle_confirmation_and_subscription(request, subscription_form, user, env): + first_name = subscription_form.cleaned_data["first_name"] + if not subscription_form.cleaned_data["last_name"]: + subscription_form.cleaned_data["last_name"] = first_name + + if env == 'SANDBOX': + subscription_params = { + 'users_count': -1, + 'api_key_count': -1, + 'project_count': -1, + 'notification_count': -1, + 'start_date': timezone.now(), + 'end_date': None + } + if isinstance(user, Researcher): + subscription_params['researcher'] = user + user.is_subscribed = True + + elif isinstance(user, Institution): + subscription_params['institution'] = user + user.is_subscribed = True + + elif isinstance(user, ServiceProvider): + subscription_params['service_provider'] = user + user.is_certified = True + + elif isinstance(user, Community): + subscription_params['community'] = user + user.is_member=True + + user.save() + response = Subscription.objects.create(**subscription_params) + return response + + elif isinstance(user, Researcher) and env != 'SANDBOX': + response = confirm_subscription( + request, user, + subscription_form, 'researcher_account' + ) + return response + + elif isinstance(user, Institution) and env != 'SANDBOX': + response = confirm_subscription( + request, user, + subscription_form, 'institution_account' + ) + data = Institution.objects.get( + institution_name=user.institution_name + ) + send_hub_admins_account_creation_email( + request, data + ) + return response + + elif isinstance(user, ServiceProvider) and env != 'SANDBOX': + response = confirm_subscription( + request, user, + subscription_form, 'service_provider_account' + ) + data = ServiceProvider.objects.get( + name=user.name + ) + send_hub_admins_account_creation_email( + request, data + ) + send_service_provider_email(request, data) + return response + + elif isinstance(user, Community) and env != 'SANDBOX': + response = confirm_subscription( + request, user, + subscription_form, 'community_account' + ) + data = Community.objects.get( + community_name = user.community_name + ) + send_hub_admins_account_creation_email(request, data) + return response + + +def get_certified_service_providers(request): + service_providers = ServiceProvider.objects.filter( + Q(is_certified=True) & + ( + (Q(certification_type='manual') & ~Q(documentation=None)) | + ~Q(certification_type='manual') + ) + ) + + q = request.GET.get('q') + if q: + vector = SearchVector('name') + query = SearchQuery(q) + results = service_providers.filter(name__icontains=q) + else: + results = service_providers + + return results diff --git a/helpers/views.py b/helpers/views.py index 0f6d09471..8a3ece679 100644 --- a/helpers/views.py +++ b/helpers/views.py @@ -16,8 +16,9 @@ from .models import NoticeDownloadTracker from institutions.models import Institution from researchers.models import Researcher -from .utils import retrieve_native_land_all_slug_data - +from serviceproviders.models import ServiceProvider +from .utils import validate_is_subscribed, retrieve_native_land_all_slug_data +from .exceptions import UnsubscribedAccountException def restricted_view(request, exception=None): return render(request, '403.html', status=403) @@ -36,26 +37,48 @@ def delete_member_invite(request, pk): if '/communities/' in request.META.get('HTTP_REFERER'): return redirect('member-requests', invite.community.id) + elif '/service-providers/' in request.META.get('HTTP_REFERER'): + return redirect('service-provider-member-intives', invite.service_provider.id) else: return redirect('institution-member-requests', invite.institution.id) @login_required(login_url='login') -def download_open_collaborate_notice(request, perm, researcher_id=None, institution_id=None): +def download_open_collaborate_notice(request, perm, researcher_id=None, institution_id=None, service_provider_id=None): # perm will be a 1 or 0 has_permission = bool(perm) if dev_prod_or_local(request.get_host()) == 'SANDBOX' or not has_permission: return redirect('restricted') else: if researcher_id: - researcher = get_object_or_404(Researcher, id=researcher_id) - NoticeDownloadTracker.objects.create(researcher=researcher, user=request.user,open_to_collaborate_notice=True) - - elif institution_id: - institution = get_object_or_404(Institution, id=institution_id) - NoticeDownloadTracker.objects.create(institution=institution, user=request.user, open_to_collaborate_notice=True) + entity = get_object_or_404(Researcher, id=researcher_id) + entity_type = 'researcher' + if institution_id: + entity = get_object_or_404(Institution, id=institution_id) + entity_type = 'institution' + if service_provider_id: + entity = get_object_or_404(ServiceProvider, id=service_provider_id) + entity_type = 'service_provider' + + if not service_provider_id and entity.is_subscribed: + entity_field = {entity_type: entity} + entity_field['user'] = request.user + entity_field['open_to_collaborate_notice'] = True + NoticeDownloadTracker.objects.create(**entity_field) + return download_otc_notice(request) + elif service_provider_id and entity.is_certified: + entity_field = {entity_type: entity} + entity_field['user'] = request.user + entity_field['open_to_collaborate_notice'] = True + NoticeDownloadTracker.objects.create(**entity_field) + return download_otc_notice(request) + else: + if service_provider_id: + message = 'Account Is Not Certified' + else: + message = 'Account Is Not Subscribed' + raise UnsubscribedAccountException(message) - return download_otc_notice(request) @login_required(login_url='login') diff --git a/institutions/decorators.py b/institutions/decorators.py index bfc110d01..76fe87b05 100644 --- a/institutions/decorators.py +++ b/institutions/decorators.py @@ -13,4 +13,4 @@ def _wrapped_view(request, *args, **kwargs): return redirect('restricted') return view_func(request, *args, **kwargs) return _wrapped_view - return decorator \ No newline at end of file + return decorator diff --git a/institutions/forms.py b/institutions/forms.py index 1080526ae..1f0b35483 100644 --- a/institutions/forms.py +++ b/institutions/forms.py @@ -9,6 +9,7 @@ class CreateInstitutionForm(forms.ModelForm): class Meta: model = Institution + fields = ['institution_name', 'ror_id', 'city_town', 'state_province_region', 'country', 'description', 'contact_name', 'contact_email',] fields = ['institution_name', 'ror_id', 'city_town', 'state_province_region', 'country', 'description', 'contact_name', 'contact_email', 'website'] error_messages = { 'institution_name': { @@ -22,8 +23,8 @@ class Meta: 'state_province_region': forms.TextInput(attrs={'id':'institutionStateProvRegion', 'class': 'w-100'}), 'description': forms.Textarea(attrs={'class': 'w-100', 'rows': 2, 'required': True}), 'country': forms.TextInput(attrs={'id':'institutionCountry', 'class': 'w-100', }), - 'contact_name': forms.TextInput(attrs={'class': 'w-100'}), - 'contact_email': forms.EmailInput(attrs={'class': 'w-100'}), + 'contact_name': forms.TextInput(attrs={'class': 'w-100', 'id': 'institutionContactNameField', 'required': True}), + 'contact_email': forms.EmailInput(attrs={'class': 'w-100', 'id': 'institutionContactEmailField', 'required': True}), 'website': forms.TextInput(attrs={'class': 'w-100'}), } @@ -39,6 +40,7 @@ class CreateInstitutionNoRorForm(forms.ModelForm): class Meta: model = Institution + fields = ['institution_name', 'city_town', 'state_province_region', 'country', 'description', 'is_ror', 'contact_name', 'contact_email',] fields = ['institution_name', 'city_town', 'state_province_region', 'country', 'description', 'is_ror', 'contact_name', 'contact_email', 'website'] error_messages = { 'institution_name': { @@ -49,6 +51,10 @@ class Meta: 'institution_name': forms.TextInput(attrs={'name':'institution_name', 'class': 'w-100', 'autocomplete': 'off', 'required': True}), 'city_town': forms.TextInput(attrs={'class': 'w-100'}), 'state_province_region': forms.TextInput(attrs={'class': 'w-100'}), + 'description': forms.Textarea(attrs={'class': 'w-100', 'rows': 2, 'required': True}), + 'country': forms.TextInput(attrs={'class': 'w-100', }), + 'contact_name': forms.TextInput(attrs={'class': 'w-100', 'id': 'institutionContactNameField', 'required': True}), + 'contact_email': forms.EmailInput(attrs={'class': 'w-100', 'id': 'institutionContactEmailField', 'required': True}), 'description': forms.Textarea(attrs={'class': 'w-100', 'rows': 2,}), 'country': forms.TextInput(attrs={'class': 'w-100', }), 'contact_name': forms.TextInput(attrs={'class': 'w-100'}), diff --git a/institutions/migrations/0032_institution_is_subscribed.py b/institutions/migrations/0032_institution_is_subscribed.py new file mode 100644 index 000000000..7f2e78800 --- /dev/null +++ b/institutions/migrations/0032_institution_is_subscribed.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2024-03-04 16:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('institutions', '0031_alter_institution_id'), + ] + + operations = [ + migrations.AddField( + model_name='institution', + name='is_subscribed', + field=models.BooleanField(default=False), + ), + ] diff --git a/institutions/migrations/0033_institution_is_submitted.py b/institutions/migrations/0033_institution_is_submitted.py new file mode 100644 index 000000000..d47a20e2a --- /dev/null +++ b/institutions/migrations/0033_institution_is_submitted.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.11 on 2024-04-15 14:04 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("institutions", "0032_institution_is_subscribed"), + ] + + operations = [ + migrations.AddField( + model_name="institution", + name="is_submitted", + field=models.BooleanField(default=False), + ), + ] diff --git a/institutions/migrations/0034_remove_institution_approved_by_and_more.py b/institutions/migrations/0034_remove_institution_approved_by_and_more.py new file mode 100644 index 000000000..14a628aa3 --- /dev/null +++ b/institutions/migrations/0034_remove_institution_approved_by_and_more.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.11 on 2024-05-03 09:18 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("institutions", "0033_institution_is_submitted"), + ] + + operations = [ + migrations.RemoveField( + model_name="institution", + name="approved_by", + ), + migrations.RemoveField( + model_name="institution", + name="is_approved", + ), + ] diff --git a/institutions/migrations/0035_remove_institution_is_submitted.py b/institutions/migrations/0035_remove_institution_is_submitted.py new file mode 100644 index 000000000..06f027926 --- /dev/null +++ b/institutions/migrations/0035_remove_institution_is_submitted.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.11 on 2024-05-28 14:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("institutions", "0034_remove_institution_approved_by_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="institution", + name="is_submitted", + ), + ] diff --git a/institutions/migrations/0036_institution_show_sp_connection_and_more.py b/institutions/migrations/0036_institution_show_sp_connection_and_more.py new file mode 100644 index 000000000..c49a2a9b0 --- /dev/null +++ b/institutions/migrations/0036_institution_show_sp_connection_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.6 on 2024-08-23 16:32 + +import django.db.models.functions.text +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('institutions', '0035_remove_institution_is_submitted'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='institution', + name='show_sp_connection', + field=models.BooleanField(default=True), + ), + migrations.AddConstraint( + model_name='institution', + constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('institution_name'), name='institution_name', violation_error_message='This institution is already on the Hub.'), + ), + ] diff --git a/institutions/migrations/0037_institution_sp_privacy.py b/institutions/migrations/0037_institution_sp_privacy.py new file mode 100644 index 000000000..14fe08efe --- /dev/null +++ b/institutions/migrations/0037_institution_sp_privacy.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-09-11 16:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('institutions', '0036_institution_show_sp_connection_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='institution', + name='sp_privacy', + field=models.CharField(choices=[('Public', 'Public/Contributor'), ('All', 'All')], default='All', max_length=20), + ), + ] diff --git a/institutions/migrations/0038_alter_institution_sp_privacy.py b/institutions/migrations/0038_alter_institution_sp_privacy.py new file mode 100644 index 000000000..9a6a3f025 --- /dev/null +++ b/institutions/migrations/0038_alter_institution_sp_privacy.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-09-11 16:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('institutions', '0037_institution_sp_privacy'), + ] + + operations = [ + migrations.AlterField( + model_name='institution', + name='sp_privacy', + field=models.CharField(choices=[('public', 'Public/Contributor Only'), ('all', 'All')], default='all', max_length=20), + ), + ] diff --git a/institutions/migrations/0039_alter_institution_sp_privacy.py b/institutions/migrations/0039_alter_institution_sp_privacy.py new file mode 100644 index 000000000..7c5a6aba1 --- /dev/null +++ b/institutions/migrations/0039_alter_institution_sp_privacy.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-09-11 16:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('institutions', '0038_alter_institution_sp_privacy'), + ] + + operations = [ + migrations.AlterField( + model_name='institution', + name='sp_privacy', + field=models.CharField(choices=[('public', 'Public/Contributor'), ('all', 'All')], default='all', max_length=20), + ), + ] diff --git a/institutions/models.py b/institutions/models.py index afb92a4c8..9044fde35 100644 --- a/institutions/models.py +++ b/institutions/models.py @@ -6,28 +6,33 @@ import uuid import os -class ApprovedManager(models.Manager): +class SubscribedManager(models.Manager): def get_queryset(self): - return super(ApprovedManager, self).get_queryset().filter(is_approved=True) + return super(SubscribedManager, self).get_queryset().filter(is_subscribed=True) def get_file_path(self, filename): ext = filename.split('.')[-1] filename = "%s.%s" % (str(uuid.uuid4()), ext) - return os.path.join('institutions/support-files', filename) + return os.path.join('institutions/support-files', filename) def institution_img_path(self, filename): ext = filename.split('.')[-1] filename = "%s.%s" % (str(uuid.uuid4()), ext) - return os.path.join('users/institution-images', filename) + return os.path.join('users/institution-images', filename) class Institution(models.Model): + PRIVACY_LEVEL = ( + ('public', 'Public/Contributor'), + ('all', 'All'), + ) + institution_creator = models.ForeignKey(User, on_delete=models.CASCADE, null=True) institution_name = models.CharField(max_length=100, null=True, unique=True) contact_name = models.CharField(max_length=80, null=True, blank=True) contact_email = models.EmailField(max_length=254, null=True, blank=True) image = models.ImageField(upload_to=institution_img_path, blank=True, null=True) support_document = models.FileField(upload_to=get_file_path, blank=True, null=True) - description = models.TextField(null=True, blank=True, validators=[MaxLengthValidator(200)]) + description = models.TextField(null=True, validators=[MaxLengthValidator(200)]) ror_id = models.CharField(max_length=80, blank=True, null=True) city_town = models.CharField(max_length=80, blank=True, null=True) state_province_region = models.CharField(verbose_name='state or province', max_length=100, blank=True, null=True) @@ -36,18 +41,20 @@ class Institution(models.Model): admins = models.ManyToManyField(User, blank=True, related_name="institution_admins") editors = models.ManyToManyField(User, blank=True, related_name="institution_editors") viewers = models.ManyToManyField(User, blank=True, related_name="institution_viewers") - is_approved = models.BooleanField(default=False, null=True) - approved_by = models.ForeignKey(User, null=True, blank=True, on_delete=models.SET_NULL, related_name="institution_approver") is_ror = models.BooleanField(default=True, null=False) created = models.DateTimeField(auto_now_add=True, null=True) + is_subscribed = models.BooleanField(default=False) + + show_sp_connection = models.BooleanField(default=True, null=True) + sp_privacy = models.CharField(max_length=20, default='all', choices=PRIVACY_LEVEL, null=True) # Managers objects = models.Manager() - approved = ApprovedManager() + subscribed = SubscribedManager() def get_location(self): components = [self.city_town, self.state_province_region, self.country] - location = ', '.join(filter(None, components)) or 'None specified' + location = ', '.join(filter(None, components)) or None return location def get_member_count(self): @@ -56,13 +63,13 @@ def get_member_count(self): viewers = self.viewers.count() total_members = admins + editors + viewers + 1 return total_members - + def get_admins(self): return self.admins.all() def get_editors(self): return self.editors.all() - + def get_viewers(self): return self.viewers.all() diff --git a/institutions/templatetags/custom_institution_tags.py b/institutions/templatetags/custom_institution_tags.py index 765dc11e6..cf4b7b46f 100644 --- a/institutions/templatetags/custom_institution_tags.py +++ b/institutions/templatetags/custom_institution_tags.py @@ -1,31 +1,10 @@ from django import template -from helpers.models import Notice -from projects.models import ProjectContributors, ProjectCreator +from projects.models import ProjectContributors register = template.Library() -# @register.simple_tag -# def anchor(url_name, section_id, institution_id): -# return reverse(url_name, kwargs={'pk': institution_id}) + f"#project-unique-{str(section_id)}" - -@register.simple_tag -def get_notices_count(institution): - return Notice.objects.filter(institution=institution, archived=False).count() - -@register.simple_tag -def get_labels_count(institution): - count = 0 - for instance in ProjectCreator.objects.select_related('project').prefetch_related('project__bc_labels', 'project__tk_labels').filter(institution=institution): - if instance.project.has_labels(): - count += 1 - - return count @register.simple_tag def institution_contributing_projects(institution): return ProjectContributors.objects.select_related('project').filter(institutions=institution) -@register.simple_tag -def connections_count(institution): - contributor_ids = institution.contributing_institutions.exclude(communities__id=None).values_list('communities__id', flat=True) - return len(contributor_ids) diff --git a/institutions/urls.py b/institutions/urls.py index f14a406b4..be04fc60e 100644 --- a/institutions/urls.py +++ b/institutions/urls.py @@ -2,6 +2,7 @@ from . import views urlpatterns = [ + # Creating/Joining Accounts path('preparation-step/', views.preparation_step, name="prep-institution"), path('connect-institution/', views.connect_institution, name="connect-institution"), path('create-institution/', views.create_institution, name="create-institution"), @@ -11,29 +12,38 @@ path('view//', views.public_institution_view, name="public-institution"), path('embed//', views.embed_otc_notice, name="embed-notice-institution"), + # Settings path('update//', views.update_institution, name="update-institution"), + path('preferences//', views.account_preferences, name="preferences-institution"), + path('api-key//', views.api_keys, name="institution-api-key"), + path('connect-service-provider//', views.connect_service_provider, name="institution-connect-service-provider"), + path('subscription-form//', views.create_institution_subscription, name="institution-create-subscription-form"), + # Notices path('notices//', views.institution_notices, name="institution-notices"), path('notices/otc/delete///', views.delete_otc_notice, name="institution-delete-otc"), + # Members path('members//', views.institution_members, name="institution-members"), path('members/requests//', views.member_requests, name="institution-member-requests"), path('members/remove//', views.remove_member, name="remove-institution-member"), - path('members/join-request/delete//', views.delete_join_request, name="institution-delete-join-request"), + # Projects: View path('projects//', views.institution_projects, name="institution-projects"), + # Projects: Create path('projects/create-project///', views.create_project, name="inst-create-project"), path('projects/create-project///', views.create_project, name="inst-create-project"), path('projects/create-project//', views.create_project, name="inst-create-project"), + # Projects: Edit path('projects/edit-project//', views.edit_project, name="inst-edit-project"), path('projects/actions///', views.project_actions, name="institution-project-actions"), path('projects/delete-project//', views.delete_project, name="inst-delete-project"), path('projects/archive-project//', views.archive_project, name="institution-archive-project"), - path('projects/unlink///', views.unlink_project, name="institution-unlink-project"), + # Connections path('connections//', views.connections, name="institution-connections"), ] \ No newline at end of file diff --git a/institutions/utils.py b/institutions/utils.py index 91967a5f8..2a3d08164 100644 --- a/institutions/utils.py +++ b/institutions/utils.py @@ -1,10 +1,48 @@ +from django.shortcuts import render, redirect +from django.db.models import Q +from django.contrib import messages from .models import Institution +from accounts.models import Subscription, UserAffiliation +from helpers.utils import change_member_role, handle_confirmation_and_subscription +from institutions.models import Institution +from helpers.models import HubActivity +from django.db import transaction + def get_institution(pk): return Institution.objects.select_related('institution_creator').prefetch_related('admins', 'editors', 'viewers').get(id=pk) + +def handle_institution_creation(request, form, subscription_form, env): + try: + with transaction.atomic(): + data = form.save(commit=False) + data.institution_creator = request.user + data.save() + if env != 'SANDBOX': + handle_confirmation_and_subscription(request, subscription_form, data, env) + affiliation = UserAffiliation.objects.prefetch_related("institutions").get(user=request.user) + affiliation.institutions.add(data) + affiliation.save() + + HubActivity.objects.create( + action_user_id=request.user.id, + action_type="New Institution", + institution_id=data.id, + action_account_type="institution", + ) + except Exception as e: + messages.add_message( + request, + messages.ERROR, + "An unexpected error has occurred here." + " Please contact support@localcontexts.org.", + ) + return redirect('dashboard') + # This is for retroactively adding ROR IDs to Institutions. # Currently not being used anywhere. + def set_ror_id(institution): import requests @@ -22,4 +60,31 @@ def set_ror_id(institution): else: print('No matching institution found.') else: - print('Error:', response.status_code) \ No newline at end of file + print('Error:', response.status_code) + +def check_subscription_and_then_change_role(request, institution, member, current_role, new_role): + try: + subscription = Subscription.objects.get(institution=institution) + except: + subscription = None + + if new_role not in ('editor', 'administrator', 'admin') and current_role in ('editor', 'administrator', 'admin'): + change_member_role(institution, member, current_role, new_role) + if subscription.users_count >= 0: + subscription.users_count += 1 + subscription.save() + elif new_role in ('editor', 'administrator', 'admin') and current_role in ('editor', 'administrator', 'admin'): + change_member_role(institution, member, current_role, new_role) + elif subscription is not None and (subscription.users_count > 0 or subscription.users_count == -1) and new_role in ('editor', 'administrator', 'admin'): + change_member_role(institution, member, current_role, new_role) + if subscription.users_count >= 0: + subscription.users_count -=1 + subscription.save() + elif subscription is None: + messages.add_message(request, messages.ERROR, + 'The subscription process of your institution is not completed yet. ' + 'Please wait for the completion of subscription process.') + else: + messages.add_message(request, messages.ERROR, + 'Your institution has reached its editors and admins limit. ' + 'Please upgrade your subscription plan to add more editors and admins.') diff --git a/institutions/views.py b/institutions/views.py index 5b63a3ec1..e7d456533 100644 --- a/institutions/views.py +++ b/institutions/views.py @@ -5,6 +5,7 @@ from django.db.models import Q from itertools import chain from .decorators import member_required +from django.shortcuts import get_object_or_404 from localcontexts.utils import dev_prod_or_local from projects.utils import * @@ -18,37 +19,55 @@ from communities.models import Community, JoinRequest from notifications.models import ActionNotification from helpers.models import * +from api.models import AccountAPIKey from django.contrib.auth.models import User -from accounts.models import UserAffiliation +from accounts.models import UserAffiliation, Subscription, ServiceProviderConnections from projects.forms import * -from helpers.forms import ProjectCommentForm, OpenToCollaborateNoticeURLForm, CollectionsCareNoticePolicyForm +from helpers.forms import ( + ProjectCommentForm, + OpenToCollaborateNoticeURLForm, + CollectionsCareNoticePolicyForm, +) from communities.forms import InviteMemberForm, JoinRequestForm -from accounts.forms import ContactOrganizationForm, SignUpInvitationForm +from accounts.forms import ( + ContactOrganizationForm, + SignUpInvitationForm, + SubscriptionForm, +) +from api.forms import APIKeyGeneratorForm from .forms import * from helpers.emails import * from maintenance_mode.decorators import force_maintenance_mode_off +from django.db import transaction -@login_required(login_url='login') + +@login_required(login_url="login") def connect_institution(request): institution = True - institutions = Institution.approved.all() + institutions = Institution.subscribed.all() form = JoinRequestForm(request.POST or None) - if request.method == 'POST': - institution_name = request.POST.get('organization_name') + if request.method == "POST": + institution_name = request.POST.get("organization_name") if Institution.objects.filter(institution_name=institution_name).exists(): institution = Institution.objects.get(institution_name=institution_name) # If join request exists or user is already a member, display Error message - request_exists = JoinRequest.objects.filter(user_from=request.user, institution=institution).exists() + request_exists = JoinRequest.objects.filter( + user_from=request.user, institution=institution + ).exists() user_is_member = institution.is_user_in_institution(request.user) if request_exists or user_is_member: - messages.add_message(request, messages.ERROR, 'Either you have already sent this request or are currently a member of this institution.') - return redirect('connect-institution') + messages.add_message( + request, + messages.ERROR, + "Either you have already sent this request or are currently a member of this institution.", + ) + return redirect("connect-institution") else: if form.is_valid(): data = form.save(commit=False) @@ -57,100 +76,117 @@ def connect_institution(request): data.user_to = institution.institution_creator data.save() - send_action_notification_join_request(data) # Send action notification to institution - send_join_request_email_admin(request, data, institution) # Send institution creator email - messages.add_message(request, messages.SUCCESS, 'Request to join institution sent!') - return redirect('connect-institution') + send_action_notification_join_request( + data + ) # Send action notification to institution + send_join_request_email_admin( + request, data, institution + ) # Send institution creator email + messages.add_message( + request, messages.SUCCESS, "Request to join institution sent!" + ) + return redirect("connect-institution") else: - messages.add_message(request, messages.ERROR, 'Institution not in registry.') - return redirect('connect-institution') - - context = { 'institution': institution, 'institutions': institutions, 'form': form,} - return render(request, 'institutions/connect-institution.html', context) + messages.add_message( + request, messages.ERROR, "Institution not in registry." + ) + return redirect("connect-institution") -@login_required(login_url='login') -def preparation_step(request): - environment = dev_prod_or_local(request.get_host()) - institution = True context = { - 'institution': institution, - 'environment': environment + "institution": institution, + "institutions": institutions, + "form": form, } - return render(request, 'accounts/preparation.html', context) - -@login_required(login_url='login') -def create_institution(request): - form = CreateInstitutionForm(request.POST or None) - - if request.method == 'POST': - affiliation = UserAffiliation.objects.prefetch_related('institutions').get(user=request.user) - if form.is_valid(): - data = form.save(commit=False) - data.institution_creator = request.user - environment = dev_prod_or_local(request.get_host()) + return render(request, "institutions/connect-institution.html", context) - # If in test site, approve immediately - if environment == 'SANDBOX': - data.is_approved = True - data.save() - - # Add to user affiliations - affiliation.institutions.add(data) +@login_required(login_url="login") +def preparation_step(request): + if dev_prod_or_local(request.get_host()) == "SANDBOX": + return redirect("create-institution") + else: + institution = True + return render( + request, "accounts/preparation.html", {"institution": institution} + ) - # Adds activity to Hub Activity - HubActivity.objects.create( - action_user_id=request.user.id, - action_type="New Institution", - institution_id=data.id, - action_account_type='institution' - ) - send_hub_admins_account_creation_email(request, data) - send_institution_email(request, data) +@login_required(login_url="login") +def create_institution(request): + form = CreateInstitutionForm() + user_form = form_initiation(request) + env = dev_prod_or_local(request.get_host()) + + if request.method == "POST": + form = CreateInstitutionForm(request.POST) + if form.is_valid() and user_form.is_valid() and validate_recaptcha(request): + mutable_post_data = request.POST.copy() + subscription_data = { + "first_name": user_form.cleaned_data['first_name'], + "last_name": user_form.cleaned_data['last_name'], + "email": request.user._wrapped.email, + "account_type": "institution_account", + "inquiry_type": request.POST['inquiry_type'], + "organization_name": form.cleaned_data['institution_name'], + } + + mutable_post_data.update(subscription_data) + subscription_form = SubscriptionForm(mutable_post_data) - messages.add_message(request, messages.INFO, - 'Your institution account has been created.') - return redirect('dashboard') + if subscription_form.is_valid(): + handle_institution_creation(request, form, subscription_form, env ) + return redirect('dashboard') + else: + messages.add_message( + request, messages.ERROR, "Something went wrong. Please Try again later.", + ) + return redirect('dashboard') + return render( + request, "institutions/create-institution.html", { + "form": form, "user_form": user_form, + } + ) - return render(request, 'institutions/create-institution.html', {'form': form }) -@login_required(login_url='login') +@login_required(login_url="login") def create_custom_institution(request): - noror_form = CreateInstitutionNoRorForm(request.POST or None) - if request.method == 'POST': - affiliation = UserAffiliation.objects.prefetch_related('institutions').get(user=request.user) - - if noror_form.is_valid(): - data = noror_form.save(commit=False) - data.institution_creator = request.user - environment = dev_prod_or_local(request.get_host()) + noror_form = CreateInstitutionNoRorForm() + user_form = form_initiation(request) + env = dev_prod_or_local(request.get_host()) - # If in test site, approve immediately - if environment == 'SANDBOX': - data.is_approved = True + if request.method == "POST": + noror_form = CreateInstitutionNoRorForm(request.POST) + if noror_form.is_valid() and user_form.is_valid() and validate_recaptcha(request): + mutable_post_data = request.POST.copy() + subscription_data = { + "first_name": user_form.cleaned_data['first_name'], + "last_name": user_form.cleaned_data['last_name'], + "email": request.user._wrapped.email, + "account_type": "institution_account", + "inquiry_type": request.POST['inquiry_type'], + "organization_name": noror_form.cleaned_data['institution_name'], + } - data.save() - - # Add to user affiliations - affiliation.institutions.add(data) - - # Adds activity to Hub Activity - HubActivity.objects.create( - action_user_id=request.user.id, - action_type="New Institution", - institution_id=data.id, - action_account_type='institution' - ) - - send_hub_admins_account_creation_email(request, data) - send_institution_email(request, data) - - messages.add_message(request, messages.INFO, - 'Your institution account has been created.') - return redirect('dashboard') - - return render(request, 'institutions/create-custom-institution.html', {'noror_form': noror_form,}) + mutable_post_data.update(subscription_data) + subscription_form = SubscriptionForm(mutable_post_data) + if subscription_form.is_valid(): + handle_institution_creation(request, noror_form, subscription_form, env ) + return redirect('dashboard') + else: + messages.add_message( + request, + messages.ERROR, + "Something went wrong. Please Try again later.", + ) + return redirect('dashboard') + return render( + request, + "institutions/create-custom-institution.html", + { + "noror_form": noror_form, + "user_form": user_form, + }, + ) def public_institution_view(request, pk): @@ -159,136 +195,196 @@ def public_institution_view(request, pk): institution = Institution.objects.get(id=pk) # Do notices exist - bcnotice = Notice.objects.filter(institution=institution, notice_type='biocultural').exists() - tknotice = Notice.objects.filter(institution=institution, notice_type='traditional_knowledge').exists() - attrnotice = Notice.objects.filter(institution=institution, notice_type='attribution_incomplete').exists() + bcnotice = Notice.objects.filter( + institution=institution, notice_type="biocultural" + ).exists() + tknotice = Notice.objects.filter( + institution=institution, notice_type="traditional_knowledge" + ).exists() + attrnotice = Notice.objects.filter( + institution=institution, notice_type="attribution_incomplete" + ).exists() otc_notices = OpenToCollaborateNoticeURL.objects.filter(institution=institution) - projects_list = list(chain( - institution.institution_created_project.all().values_list('project__unique_id', flat=True), # institution created project ids - institution.contributing_institutions.all().values_list('project__unique_id', flat=True), # projects where institution is contributor - )) - project_ids = list(set(projects_list)) # remove duplicate ids - archived = ProjectArchived.objects.filter(project_uuid__in=project_ids, institution_id=institution.id, archived=True).values_list('project_uuid', flat=True) # check ids to see if they are archived - projects = Project.objects.select_related('project_creator').filter(unique_id__in=project_ids, project_privacy='Public').exclude(unique_id__in=archived).order_by('-date_modified') - + projects_list = list( + chain( + institution.institution_created_project.all().values_list( + "project__unique_id", flat=True + ), # institution created project ids + institution.contributing_institutions.all().values_list( + "project__unique_id", flat=True + ), # projects where institution is contributor + ) + ) + project_ids = list(set(projects_list)) # remove duplicate ids + archived = ProjectArchived.objects.filter( + project_uuid__in=project_ids, institution_id=institution.id, archived=True + ).values_list( + "project_uuid", flat=True + ) # check ids to see if they are archived + projects = ( + Project.objects.select_related("project_creator") + .filter(unique_id__in=project_ids, project_privacy="Public") + .exclude(unique_id__in=archived) + .order_by("-date_modified") + ) + if request.user.is_authenticated: - user_institutions = UserAffiliation.objects.prefetch_related('institutions').get(user=request.user).institutions.all() + user_institutions = ( + UserAffiliation.objects.prefetch_related("institutions") + .get(user=request.user) + .institutions.all() + ) form = ContactOrganizationForm(request.POST or None) join_form = JoinRequestForm(request.POST or None) - if request.method == 'POST': - if 'contact_btn' in request.POST: + if request.method == "POST": + if "contact_btn" in request.POST: # contact institution if form.is_valid(): - from_name = form.cleaned_data['name'] - from_email = form.cleaned_data['email'] - message = form.cleaned_data['message'] + from_name = form.cleaned_data["name"] + from_email = form.cleaned_data["email"] + message = form.cleaned_data["message"] to_email = institution.institution_creator.email - - send_contact_email(request, to_email, from_name, from_email, message, institution) - messages.add_message(request, messages.SUCCESS, 'Message sent!') - return redirect('public-institution', institution.id) - else: - if not form.data['message']: - messages.add_message(request, messages.ERROR, 'Unable to send an empty message.') - return redirect('public-institution', institution.id) - elif 'join_request' in request.POST: + send_contact_email( + request, + to_email, + from_name, + from_email, + message, + institution, + ) + messages.add_message(request, messages.SUCCESS, "Message sent!") + return redirect("public-institution", institution.id) + else: + if not form.data["message"]: + messages.add_message( + request, + messages.ERROR, + "Unable to send an empty message.", + ) + return redirect("public-institution", institution.id) + + elif "join_request" in request.POST: if join_form.is_valid(): data = join_form.save(commit=False) - if JoinRequest.objects.filter(user_from=request.user, institution=institution).exists(): - messages.add_message(request, messages.ERROR, 'You have already sent a request to this institution.') - return redirect('public-institution', institution.id) + if JoinRequest.objects.filter( + user_from=request.user, institution=institution + ).exists(): + messages.add_message( + request, + messages.ERROR, + "You have already sent a request to this institution.", + ) + return redirect("public-institution", institution.id) else: data.user_from = request.user data.institution = institution data.user_to = institution.institution_creator data.save() - send_action_notification_join_request(data) # Send action notification to institution - send_join_request_email_admin(request, data, institution) # Send email to institution creator - return redirect('public-institution', institution.id) + send_action_notification_join_request( + data + ) # Send action notification to institution + send_join_request_email_admin( + request, data, institution + ) # Send email to institution creator + return redirect("public-institution", institution.id) else: - messages.add_message(request, messages.ERROR, 'Something went wrong.') - return redirect('public-institution', institution.id) + messages.add_message( + request, messages.ERROR, "Something went wrong." + ) + return redirect("public-institution", institution.id) else: - context = { - 'institution': institution, - 'projects' : projects, - 'bcnotice': bcnotice, - 'tknotice': tknotice, - 'attrnotice': attrnotice, - 'otc_notices': otc_notices, - 'env': environment, + context = { + "institution": institution, + "projects": projects, + "bcnotice": bcnotice, + "tknotice": tknotice, + "attrnotice": attrnotice, + "otc_notices": otc_notices, + "env": environment, } - return render(request, 'public.html', context) - - context = { - 'institution': institution, - 'projects' : projects, - 'form': form, - 'join_form': join_form, - 'user_institutions': user_institutions, - 'bcnotice': bcnotice, - 'tknotice': tknotice, - 'attrnotice': attrnotice, - 'otc_notices': otc_notices, - 'env': environment, + return render(request, "public.html", context) + + context = { + "institution": institution, + "projects": projects, + "form": form, + "join_form": join_form, + "user_institutions": user_institutions, + "bcnotice": bcnotice, + "tknotice": tknotice, + "attrnotice": attrnotice, + "otc_notices": otc_notices, + "env": environment, } - return render(request, 'public.html', context) + return render(request, "public.html", context) except: raise Http404() + # Update institution -@login_required(login_url='login') -@member_required(roles=['admin', 'editor', 'viewer']) +@login_required(login_url="login") +@member_required(roles=["admin", "editor", "viewer"]) def update_institution(request, pk): institution = get_institution(pk) member_role = check_member_role(request.user, institution) + envi = dev_prod_or_local(request.get_host()) if request.method == "POST": - update_form = UpdateInstitutionForm(request.POST, request.FILES, instance=institution) + update_form = UpdateInstitutionForm( + request.POST, request.FILES, instance=institution + ) - if 'clear_image' in request.POST: + if "clear_image" in request.POST: institution.image = None institution.save() - return redirect('update-institution', institution.id) + return redirect("update-institution", institution.id) else: if update_form.is_valid(): update_form.save() - messages.add_message(request, messages.SUCCESS, 'Settings updated!') - return redirect('update-institution', institution.id) + messages.add_message(request, messages.SUCCESS, "Settings updated!") + return redirect("update-institution", institution.id) else: update_form = UpdateInstitutionForm(instance=institution) context = { - 'institution': institution, - 'update_form': update_form, - 'member_role': member_role, + "institution": institution, + "update_form": update_form, + "member_role": member_role, + "envi": envi, } + return render(request, 'account_settings_pages/_update-account.html', context) - return render(request, 'institutions/update-institution.html', context) # Notices -@login_required(login_url='login') -@member_required(roles=['admin', 'editor', 'viewer']) +@login_required(login_url="login") +@member_required(roles=["admin", "editor", "viewer"]) def institution_notices(request, pk): institution = get_institution(pk) member_role = check_member_role(request.user, institution) - urls = OpenToCollaborateNoticeURL.objects.filter(institution=institution).values_list('url', 'name', 'id') + urls = OpenToCollaborateNoticeURL.objects.filter( + institution=institution + ).values_list("url", "name", "id") form = OpenToCollaborateNoticeURLForm(request.POST or None) - cc_policy_form = CollectionsCareNoticePolicyForm(request.POST or None, request.FILES) - - if not institution.is_approved: - not_approved_download_notice = "Your institution account needs to be confirmed in order to download this Notice." - not_approved_shared_notice = "Your institution account needs to be confirmed in order to share this Notice." - else: + cc_policy_form = CollectionsCareNoticePolicyForm( + request.POST or None, request.FILES + ) + + try: + subscription = Subscription.objects.get(institution=institution) not_approved_download_notice = None not_approved_shared_notice = None + except Subscription.DoesNotExist: + subscription = None + not_approved_download_notice = "Your institution account needs to be subscribed in order to download this Notice." + not_approved_shared_notice = "Your institution account needs to be subscribed in order to share this Notice." + # sets permission to download OTC Notice - if dev_prod_or_local(request.get_host()) == 'SANDBOX': + if dev_prod_or_local(request.get_host()) == "SANDBOX": is_sandbox = True otc_download_perm = 0 ccn_download_perm = 0 @@ -296,13 +392,13 @@ def institution_notices(request, pk): share_notice_on_sandbox = "Sharing of Notices is not available on the sandbox site." else: is_sandbox = False - otc_download_perm = 1 if institution.is_approved else 0 - ccn_download_perm = 1 if institution.is_approved else 0 + otc_download_perm = 1 if institution.is_subscribed else 0 + ccn_download_perm = 1 if institution.is_subscribed else 0 download_notice_on_sandbox = None share_notice_on_sandbox = None - if request.method == 'POST': - if 'add_policy' in request.POST: + if request.method == "POST": + if "add_policy" in request.POST: if cc_policy_form.is_valid(): cc_data = cc_policy_form.save(commit=False) cc_data.institution = institution @@ -317,258 +413,428 @@ def institution_notices(request, pk): action_user_id=request.user.id, action_type="Engagement Notice Added", project_id=data.id, - action_account_type = 'institution', - institution_id=institution.id + action_account_type="institution", + institution_id=institution.id, ) - return redirect('institution-notices', institution.id) + return redirect("institution-notices", institution.id) context = { - 'institution': institution, - 'member_role': member_role, - 'form': form, - 'cc_policy_form': cc_policy_form, - 'urls': urls, - 'otc_download_perm': otc_download_perm, - 'ccn_download_perm': ccn_download_perm, - 'is_sandbox': is_sandbox, + "institution": institution, + "member_role": member_role, + "form": form, + "cc_policy_form": cc_policy_form, + "urls": urls, + "otc_download_perm": otc_download_perm, + "ccn_download_perm": ccn_download_perm, + "is_sandbox": is_sandbox, 'not_approved_download_notice': not_approved_download_notice, 'download_notice_on_sandbox': download_notice_on_sandbox, 'not_approved_shared_notice': not_approved_shared_notice, 'share_notice_on_sandbox': share_notice_on_sandbox, - + "subscription": subscription, } - return render(request, 'institutions/notices.html', context) + return render(request, "institutions/notices.html", context) -@login_required(login_url='login') -@member_required(roles=['admin', 'editor']) + +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) def delete_otc_notice(request, pk, notice_id): if OpenToCollaborateNoticeURL.objects.filter(id=notice_id).exists(): otc = OpenToCollaborateNoticeURL.objects.get(id=notice_id) otc.delete() - return redirect('institution-notices', pk) + return redirect("institution-notices", pk) + # Members -@login_required(login_url='login') -@member_required(roles=['admin', 'editor', 'viewer']) +@login_required(login_url="login") +@member_required(roles=["admin", "editor", "viewer"]) def institution_members(request, pk): institution = get_institution(pk) member_role = check_member_role(request.user, institution) # Get list of users, NOT in this institution, alphabetized by name - members = list(chain( - institution.admins.all().values_list('id', flat=True), - institution.editors.all().values_list('id', flat=True), - institution.viewers.all().values_list('id', flat=True), - )) - members.append(institution.institution_creator.id) # include institution creator - users = User.objects.exclude(id__in=members).order_by('username') + members = list( + chain( + institution.admins.all().values_list("id", flat=True), + institution.editors.all().values_list("id", flat=True), + institution.viewers.all().values_list("id", flat=True), + ) + ) + members.append(institution.institution_creator.id) # include institution creator + users = User.objects.exclude(id__in=members).order_by("username") join_requests_count = JoinRequest.objects.filter(institution=institution).count() - form = InviteMemberForm(request.POST or None) + try: + subscription = Subscription.objects.get(institution=institution) + except Subscription.DoesNotExist: + subscription = None + form = InviteMemberForm(request.POST or None, subscription=subscription) - if request.method == 'POST': - if 'change_member_role_btn' in request.POST: - current_role = request.POST.get('current_role') - new_role = request.POST.get('new_role') - user_id = request.POST.get('user_id') + if request.method == "POST": + if "change_member_role_btn" in request.POST: + current_role = request.POST.get("current_role") + new_role = request.POST.get("new_role") + user_id = request.POST.get("user_id") member = User.objects.get(id=user_id) - change_member_role(institution, member, current_role, new_role) - return redirect('institution-members', institution.id) + check_subscription_and_then_change_role(request, institution, member, current_role, new_role) + return redirect("institution-members", institution.id) - elif 'send_invite_btn' in request.POST: + elif "send_invite_btn" in request.POST: selected_user = User.objects.none() if form.is_valid(): data = form.save(commit=False) # Get target User - selected_username = request.POST.get('userList') - username_to_check = '' + selected_username = request.POST.get("userList") + username_to_check = "" - if ' ' in selected_username: #if username includes spaces means it has a first and last name (last name,first name) - x = selected_username.split(' ') + if ( + " " in selected_username + ): # if username includes spaces means it has a first and last name (last name,first name) + x = selected_username.split(" ") username_to_check = x[0] else: username_to_check = selected_username - if not username_to_check in users.values_list('username', flat=True): - messages.add_message(request, messages.INFO, 'Invalid user selection. Please select user from the list.') + if not username_to_check in users.values_list("username", flat=True): + messages.add_message( + request, + messages.INFO, + "Invalid user selection. Please select user from the list.", + ) else: selected_user = User.objects.get(username=username_to_check) # Check to see if an invite or join request aleady exists - invitation_exists = InviteMember.objects.filter(receiver=selected_user, institution=institution).exists() # Check to see if invitation already exists - join_request_exists = JoinRequest.objects.filter(user_from=selected_user, institution=institution).exists() # Check to see if join request already exists - - if not invitation_exists and not join_request_exists: # If invitation and join request does not exist, save form + invitation_exists = InviteMember.objects.filter( + receiver=selected_user, institution=institution + ).exists() # Check to see if invitation already exists + join_request_exists = JoinRequest.objects.filter( + user_from=selected_user, institution=institution + ).exists() # Check to see if join request already exists + if request.POST.get('role') in ('editor', 'administrator', 'admin') and subscription == None: + messages.error(request, 'The subscription process of your institution is not completed yet. Please wait for the completion of subscription process.') + return redirect('institution-members', institution.id) + elif request.POST.get('role') in ('editor', 'administrator', 'admin') and subscription.users_count == 0: + messages.error(request, 'The editor and admin limit for this institution has been reached. Please contact the institution and let them know to upgrade their subscription plan to add more editors and admins.') + return redirect('institution-members', institution.id) + + if ( + not invitation_exists and not join_request_exists + ): # If invitation and join request does not exist, save form data.receiver = selected_user data.sender = request.user - data.status = 'sent' + data.status = "sent" data.institution = institution data.save() - - send_account_member_invite(data) # Send action notification - send_member_invite_email(request, data, institution) # Send email to target user - messages.add_message(request, messages.INFO, f'Invitation sent to {selected_user}.') - return redirect('institution-members', institution.id) - else: - messages.add_message(request, messages.INFO, f'The user you are trying to add already has an invitation pending to join {institution.institution_name}.') + + send_account_member_invite(data) # Send action notification + send_member_invite_email( + request, data, institution + ) # Send email to target user + messages.add_message( + request, + messages.INFO, + f"Invitation sent to {selected_user}.", + ) + return redirect("institution-members", institution.id) + else: + messages.add_message( + request, + messages.INFO, + f"The user you are trying to add already has an invitation pending to join {institution.institution_name}.", + ) else: - messages.add_message(request, messages.INFO, 'Something went wrong.') + messages.add_message(request, messages.INFO, "Something went wrong.") - context = { - 'institution': institution, - 'form': form, - 'member_role': member_role, - 'join_requests_count': join_requests_count, - 'users': users, - 'invite_form': SignUpInvitationForm(), - 'env': dev_prod_or_local(request.get_host()), - } - return render(request, 'institutions/members.html', context) + context = { + "institution": institution, + "form": form, + "member_role": member_role, + "join_requests_count": join_requests_count, + "users": users, + "invite_form": SignUpInvitationForm(), + "env": dev_prod_or_local(request.get_host()), + "subscription": subscription, + } + return render(request, "institutions/members.html", context) -@login_required(login_url='login') -@member_required(roles=['admin', 'editor', 'viewer']) + +@login_required(login_url="login") +@member_required(roles=["admin", "editor", "viewer"]) def member_requests(request, pk): institution = get_institution(pk) member_role = check_member_role(request.user, institution) join_requests = JoinRequest.objects.filter(institution=institution) member_invites = InviteMember.objects.filter(institution=institution) + try: + subscription = Subscription.objects.get(institution=institution) + except Subscription.DoesNotExist: + subscription = None if request.method == 'POST': selected_role = request.POST.get('selected_role') join_request_id = request.POST.get('join_request_id') - + if check_subscription(request, institution) and selected_role.lower() in ('editor', 'administrator', 'admin'): + messages.add_message(request, messages.ERROR, 'The subscription process of your institution is not completed yet. Please wait for the completion of subscription process.') + return redirect('institution-members', institution.id) + accepted_join_request(request, institution, join_request_id, selected_role) - messages.add_message(request, messages.SUCCESS, 'You have successfully added a new member!') return redirect('institution-member-requests', institution.id) context = { - 'member_role': member_role, - 'institution': institution, - 'join_requests': join_requests, - 'member_invites': member_invites, + "member_role": member_role, + "institution": institution, + "join_requests": join_requests, + "member_invites": member_invites, + "subscription": subscription, } - return render(request, 'institutions/member-requests.html', context) + return render(request, "institutions/member-requests.html", context) -@login_required(login_url='login') -@member_required(roles=['admin']) + +@login_required(login_url="login") +@member_required(roles=["admin"]) def delete_join_request(request, pk, join_id): institution = get_institution(pk) join_request = JoinRequest.objects.get(id=join_id) join_request.delete() return redirect('institution-member-requests', institution.id) - + @login_required(login_url='login') @member_required(roles=['admin']) def remove_member(request, pk, member_id): institution = get_institution(pk) member = User.objects.get(id=member_id) + try: + subscription = Subscription.objects.get(institution=institution) + except Subscription.DoesNotExist: + subscription = None + + + if subscription is not None and subscription.users_count >= 0 and member in (institution.admins.all() or institution.editors.all()): + subscription.users_count += 1 + subscription.save() remove_user_from_account(member, institution) # Delete join request for this institution if exists if JoinRequest.objects.filter(user_from=member, institution=institution).exists(): - join_request = JoinRequest.objects.get(user_from=member, institution=institution) + join_request = JoinRequest.objects.get( + user_from=member, institution=institution + ) join_request.delete() - title = f'You have been removed as a member from {institution.institution_name}.' - UserNotification.objects.create(from_user=request.user, to_user=member, title=title, notification_type="Remove", institution=institution) - - if '/manage/' in request.META.get('HTTP_REFERER'): - return redirect('manage-orgs') + title = f"You have been removed as a member from {institution.institution_name}." + UserNotification.objects.create( + from_user=request.user, + to_user=member, + title=title, + notification_type="Remove", + institution=institution, + ) + + if "/manage/" in request.META.get("HTTP_REFERER"): + return redirect("manage-orgs") else: - return redirect('institution-members', institution.id) + return redirect("institution-members", institution.id) + -# Projects page +# Projects page @login_required(login_url='login') @member_required(roles=['admin', 'editor', 'viewer']) def institution_projects(request, pk): institution = get_institution(pk) member_role = check_member_role(request.user, institution) - + try: + subscription = Subscription.objects.get(institution=institution) + except Subscription.DoesNotExist: + subscription = None bool_dict = { - 'has_labels': False, - 'has_notices': False, - 'created': False, - 'contributed': False, - 'is_archived': False, - 'title_az': False, - 'visibility_public': False, - 'visibility_contributor': False, - 'visibility_private': False, - 'date_modified': False + "has_labels": False, + "has_notices": False, + "created": False, + "contributed": False, + "is_archived": False, + "title_az": False, + "visibility_public": False, + "visibility_contributor": False, + "visibility_private": False, + "date_modified": False, } - # 1. institution projects + - # 2. projects institution has been notified of + # 1. institution projects + + # 2. projects institution has been notified of # 3. projects where institution is contributor - projects_list = list(chain( - institution.institution_created_project.all().values_list('project__unique_id', flat=True), - institution.institutions_notified.all().values_list('project__unique_id', flat=True), - institution.contributing_institutions.all().values_list('project__unique_id', flat=True), - )) - project_ids = list(set(projects_list)) # remove duplicate ids - archived = ProjectArchived.objects.filter(project_uuid__in=project_ids, institution_id=institution.id, archived=True).values_list('project_uuid', flat=True) # check ids to see if they are archived - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids).exclude(unique_id__in=archived).order_by('-date_added') - - sort_by = request.GET.get('sort') - if sort_by == 'all': - return redirect('institution-projects', institution.id) - - elif sort_by == 'has_labels': - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids - ).exclude(unique_id__in=archived).exclude(bc_labels=None).order_by('-date_added') | Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids - ).exclude(unique_id__in=archived).exclude(tk_labels=None).order_by('-date_added') - bool_dict['has_labels'] = True - - elif sort_by == 'has_notices': - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids, tk_labels=None, bc_labels=None).exclude(unique_id__in=archived).order_by('-date_added') - bool_dict['has_notices'] = True - - elif sort_by == 'created': - created_projects = institution.institution_created_project.all().values_list('project__unique_id', flat=True) - archived = ProjectArchived.objects.filter(project_uuid__in=created_projects, institution_id=institution.id, archived=True).values_list('project_uuid', flat=True) # check ids to see if they are archived - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=created_projects).exclude(unique_id__in=archived).order_by('-date_added') - bool_dict['created'] = True - - elif sort_by == 'contributed': - contrib = institution.contributing_institutions.all().values_list('project__unique_id', flat=True) - projects_list = list(chain( - institution.institution_created_project.all().values_list('project__unique_id', flat=True), # check institution created projects - ProjectArchived.objects.filter(project_uuid__in=contrib, institution_id=institution.id, archived=True).values_list('project_uuid', flat=True) # check ids to see if they are archived - )) - project_ids = list(set(projects_list)) # remove duplicate ids - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=contrib).exclude(unique_id__in=project_ids).order_by('-date_added') - bool_dict['contributed'] = True - - elif sort_by == 'archived': - archived_projects = ProjectArchived.objects.filter(institution_id=institution.id, archived=True).values_list('project_uuid', flat=True) - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=archived_projects).order_by('-date_added') - bool_dict['is_archived'] = True - - elif sort_by == 'title_az': - projects = projects.order_by('title') - bool_dict['title_az'] = True - - elif sort_by == 'visibility_public': - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids, project_privacy='Public').exclude(unique_id__in=archived).order_by('-date_added') - bool_dict['visibility_public'] = True - - elif sort_by == 'visibility_contributor': - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids, project_privacy='Contributor').exclude(unique_id__in=archived).order_by('-date_added') - bool_dict['visibility_contributor'] = True - - elif sort_by == 'visibility_private': - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids, project_privacy='Private').exclude(unique_id__in=archived).order_by('-date_added') - bool_dict['visibility_private'] = True - - elif sort_by == 'date_modified': - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids).exclude(unique_id__in=archived).order_by('-date_modified') - bool_dict['date_modified'] = True + projects_list = list( + chain( + institution.institution_created_project.all().values_list( + "project__unique_id", flat=True + ), + institution.institutions_notified.all().values_list( + "project__unique_id", flat=True + ), + institution.contributing_institutions.all().values_list( + "project__unique_id", flat=True + ), + ) + ) + project_ids = list(set(projects_list)) # remove duplicate ids + archived = ProjectArchived.objects.filter( + project_uuid__in=project_ids, institution_id=institution.id, archived=True + ).values_list( + "project_uuid", flat=True + ) # check ids to see if they are archived + projects = ( + Project.objects.select_related("project_creator") + .prefetch_related("bc_labels", "tk_labels") + .filter(unique_id__in=project_ids) + .exclude(unique_id__in=archived) + .order_by("-date_added") + ) + + sort_by = request.GET.get("sort") + if sort_by == "all": + return redirect("institution-projects", institution.id) + + elif sort_by == "has_labels": + projects = Project.objects.select_related("project_creator").prefetch_related( + "bc_labels", "tk_labels" + ).filter(unique_id__in=project_ids).exclude(unique_id__in=archived).exclude( + bc_labels=None + ).order_by( + "-date_added" + ) | Project.objects.select_related( + "project_creator" + ).prefetch_related( + "bc_labels", "tk_labels" + ).filter( + unique_id__in=project_ids + ).exclude( + unique_id__in=archived + ).exclude( + tk_labels=None + ).order_by( + "-date_added" + ) + bool_dict["has_labels"] = True + + elif sort_by == "has_notices": + projects = ( + Project.objects.select_related("project_creator") + .prefetch_related("bc_labels", "tk_labels") + .filter(unique_id__in=project_ids, tk_labels=None, bc_labels=None) + .exclude(unique_id__in=archived) + .order_by("-date_added") + ) + bool_dict["has_notices"] = True + + elif sort_by == "created": + created_projects = institution.institution_created_project.all().values_list( + "project__unique_id", flat=True + ) + archived = ProjectArchived.objects.filter( + project_uuid__in=created_projects, + institution_id=institution.id, + archived=True, + ).values_list( + "project_uuid", flat=True + ) # check ids to see if they are archived + projects = ( + Project.objects.select_related("project_creator") + .prefetch_related("bc_labels", "tk_labels") + .filter(unique_id__in=created_projects) + .exclude(unique_id__in=archived) + .order_by("-date_added") + ) + bool_dict["created"] = True + + elif sort_by == "contributed": + contrib = institution.contributing_institutions.all().values_list( + "project__unique_id", flat=True + ) + projects_list = list( + chain( + institution.institution_created_project.all().values_list( + "project__unique_id", flat=True + ), # check institution created projects + ProjectArchived.objects.filter( + project_uuid__in=contrib, + institution_id=institution.id, + archived=True, + ).values_list( + "project_uuid", flat=True + ), # check ids to see if they are archived + ) + ) + project_ids = list(set(projects_list)) # remove duplicate ids + projects = ( + Project.objects.select_related("project_creator") + .prefetch_related("bc_labels", "tk_labels") + .filter(unique_id__in=contrib) + .exclude(unique_id__in=project_ids) + .order_by("-date_added") + ) + bool_dict["contributed"] = True + + elif sort_by == "archived": + archived_projects = ProjectArchived.objects.filter( + institution_id=institution.id, archived=True + ).values_list("project_uuid", flat=True) + projects = ( + Project.objects.select_related("project_creator") + .prefetch_related("bc_labels", "tk_labels") + .filter(unique_id__in=archived_projects) + .order_by("-date_added") + ) + bool_dict["is_archived"] = True + + elif sort_by == "title_az": + projects = projects.order_by("title") + bool_dict["title_az"] = True + + elif sort_by == "visibility_public": + projects = ( + Project.objects.select_related("project_creator") + .prefetch_related("bc_labels", "tk_labels") + .filter(unique_id__in=project_ids, project_privacy="Public") + .exclude(unique_id__in=archived) + .order_by("-date_added") + ) + bool_dict["visibility_public"] = True + + elif sort_by == "visibility_contributor": + projects = ( + Project.objects.select_related("project_creator") + .prefetch_related("bc_labels", "tk_labels") + .filter(unique_id__in=project_ids, project_privacy="Contributor") + .exclude(unique_id__in=archived) + .order_by("-date_added") + ) + bool_dict["visibility_contributor"] = True + + elif sort_by == "visibility_private": + projects = ( + Project.objects.select_related("project_creator") + .prefetch_related("bc_labels", "tk_labels") + .filter(unique_id__in=project_ids, project_privacy="Private") + .exclude(unique_id__in=archived) + .order_by("-date_added") + ) + bool_dict["visibility_private"] = True + + elif sort_by == "date_modified": + projects = ( + Project.objects.select_related("project_creator") + .prefetch_related("bc_labels", "tk_labels") + .filter(unique_id__in=project_ids) + .exclude(unique_id__in=archived) + .order_by("-date_modified") + ) + bool_dict["date_modified"] = True page = paginate(request, projects, 10) - - if request.method == 'GET': + + if request.method == "GET": results = return_project_search_results(request, projects) context = { @@ -578,13 +844,15 @@ def institution_projects(request, pk): 'items': page, 'results': results, 'bool_dict': bool_dict, + 'subscription': subscription, } - return render(request, 'institutions/projects.html', context) + return render(request, "institutions/projects.html", context) # Create Project -@login_required(login_url='login') -@member_required(roles=['admin', 'editor']) +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) +@transaction.atomic def create_project(request, pk, source_proj_uuid=None, related=None): institution = get_institution(pk) member_role = check_member_role(request.user, institution) @@ -592,22 +860,29 @@ def create_project(request, pk, source_proj_uuid=None, related=None): notice_translations = get_notice_translations() notice_defaults = get_notice_defaults() + if check_subscription(request, 'institution', pk): + return redirect('institution-projects', institution.id) + + subscription = Subscription.objects.get(institution=institution) if request.method == 'GET': form = CreateProjectForm(request.GET or None) formset = ProjectPersonFormset(queryset=ProjectPerson.objects.none()) elif request.method == "POST": form = CreateProjectForm(request.POST) formset = ProjectPersonFormset(request.POST) + subscription = Subscription.objects.get(institution=institution) if form.is_valid() and formset.is_valid(): data = form.save(commit=False) data.project_creator = request.user # Define project_page field - data.project_page = f'{request.scheme}://{request.get_host()}/projects/{data.unique_id}' - + data.project_page = ( + f"{request.scheme}://{request.get_host()}/projects/{data.unique_id}" + ) + # Handle multiple urls, save as array - project_links = request.POST.getlist('project_urls') + project_links = request.POST.getlist("project_urls") data.urls = project_links create_or_update_boundary( @@ -615,12 +890,19 @@ def create_project(request, pk, source_proj_uuid=None, related=None): entity=data ) + if subscription.project_count > 0: + subscription.project_count -= 1 + subscription.save() + data.save() if source_proj_uuid and not related: data.source_project_uuid = source_proj_uuid data.save() - ProjectActivity.objects.create(project=data, activity=f'Sub Project "{data.title}" was added to Project by {name} | {institution.institution_name}') + ProjectActivity.objects.create( + project=data, + activity=f'Sub Project "{data.title}" was added to Project by {name} | {institution.institution_name}', + ) if source_proj_uuid and related: source = Project.objects.get(unique_id=source_proj_uuid) @@ -629,31 +911,49 @@ def create_project(request, pk, source_proj_uuid=None, related=None): source.save() data.save() - ProjectActivity.objects.create(project=data, activity=f'Project "{source.title}" was connected to Project by {name} | {institution.institution_name}') - ProjectActivity.objects.create(project=source, activity=f'Project "{data.title}" was connected to Project by {name} | {institution.institution_name}') + ProjectActivity.objects.create( + project=data, + activity=f'Project "{source.title}" was connected to Project by {name} | {institution.institution_name}', + ) + ProjectActivity.objects.create( + project=source, + activity=f'Project "{data.title}" was connected to Project by {name} | {institution.institution_name}', + ) # Create activity - ProjectActivity.objects.create(project=data, activity=f'Project was created by {name} | {institution.institution_name}') + ProjectActivity.objects.create( + project=data, + activity=f"Project was created by {name} | {institution.institution_name}", + ) # Adds activity to Hub Activity HubActivity.objects.create( action_user_id=request.user.id, action_type="Project Created", project_id=data.id, - action_account_type = 'institution', - institution_id=institution.id + action_account_type="institution", + institution_id=institution.id, ) # Add project to institution projects - creator = ProjectCreator.objects.select_related('institution').get(project=data) + creator = ProjectCreator.objects.select_related("institution").get( + project=data + ) creator.institution = institution creator.save() # Create notices for project - notices_selected = request.POST.getlist('checkbox-notice') - translations_selected = request.POST.getlist('checkbox-translation') - crud_notices(request, notices_selected, translations_selected, institution, data, None, False) - + notices_selected = request.POST.getlist("checkbox-notice") + translations_selected = request.POST.getlist("checkbox-translation") + crud_notices( + request, + notices_selected, + translations_selected, + institution, + data, + None, False, + ) + # Add selected contributors to the ProjectContributors object add_to_contributors(request, institution, data) @@ -663,28 +963,39 @@ def create_project(request, pk, source_proj_uuid=None, related=None): if instance.name or instance.email: instance.project = data instance.save() - + # Send email to added person - send_project_person_email(request, instance.email, data.unique_id, institution) + send_project_person_email( + request, instance.email, data.unique_id, institution + ) # Format and send notification about the created project truncated_project_title = str(data.title)[0:30] - title = f'A new project was created by {name}: {truncated_project_title} ...' - ActionNotification.objects.create(title=title, notification_type='Projects', sender=data.project_creator, reference_id=data.unique_id, institution=institution) - return redirect('institution-projects', institution.id) + title = ( + f"A new project was created by {name}: {truncated_project_title} ..." + ) + ActionNotification.objects.create( + title=title, + notification_type="Projects", + sender=data.project_creator, + reference_id=data.unique_id, + institution=institution, + ) + return redirect("institution-projects", institution.id) context = { - 'institution': institution, - 'notice_translations': notice_translations, - 'notice_defaults': notice_defaults, - 'form': form, - 'formset': formset, - 'member_role': member_role, + "institution": institution, + "notice_translations": notice_translations, + "notice_defaults": notice_defaults, + "form": form, + "formset": formset, + "member_role": member_role, } - return render(request, 'institutions/create-project.html', context) + return render(request, "institutions/create-project.html", context) -@login_required(login_url='login') -@member_required(roles=['admin', 'editor']) + +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) def edit_project(request, pk, project_uuid): institution = get_institution(pk) project = Project.objects.get(unique_id=project_uuid) @@ -696,7 +1007,6 @@ def edit_project(request, pk, project_uuid): notices = Notice.objects.none() notice_translations = get_notice_translations() notice_defaults = get_notice_defaults() - # Check to see if notice exists for this project and pass to template if Notice.objects.filter(project=project).exists(): notices = Notice.objects.filter(project=project, archived=False) @@ -707,7 +1017,7 @@ def edit_project(request, pk, project_uuid): if form.is_valid() and formset.is_valid(): has_changes = form.has_changed() data = form.save(commit=False) - project_links = request.POST.getlist('project_urls') + project_links = request.POST.getlist("project_urls") data.urls = project_links create_or_update_boundary( @@ -725,8 +1035,8 @@ def edit_project(request, pk, project_uuid): action_user_id=request.user.id, action_type="Project Edited", project_id=data.id, - action_account_type = 'institution', - institution_id=pk + action_account_type="institution", + institution_id=pk, ) instances = formset.save(commit=False) @@ -752,76 +1062,118 @@ def edit_project(request, pk, project_uuid): context = { - 'member_role': member_role, - 'institution': institution, - 'project': project, - 'notices': notices, - 'notice_defaults': notice_defaults, - 'form': form, - 'formset': formset, - 'contributors': contributors, - 'urls': project.urls, - 'notice_translations': notice_translations, - 'boundary_reset_url': reverse('reset-project-boundary', kwargs={'pk': project.id}), - 'boundary_preview_url': reverse('project-boundary-view', kwargs={'project_id': project.id}), + "member_role": member_role, + "institution": institution, + "project": project, + "notices": notices, + "notice_defaults": notice_defaults, + "form": form, + "formset": formset, + "contributors": contributors, + "urls": project.urls, + "notice_translations": notice_translations, } - return render(request, 'institutions/edit-project.html', context) + return render(request, "institutions/edit-project.html", context) + def project_actions(request, pk, project_uuid): try: institution = get_institution(pk) project = Project.objects.prefetch_related( - 'bc_labels', - 'tk_labels', - 'bc_labels__community', - 'tk_labels__community', - 'bc_labels__bclabel_translation', - 'tk_labels__tklabel_translation', - ).get(unique_id=project_uuid) - + "bc_labels", + "tk_labels", + "bc_labels__community", + "tk_labels__community", + "bc_labels__bclabel_translation", + "tk_labels__tklabel_translation", + ).get(unique_id=project_uuid) + + subscription = Subscription.objects.filter(institution=institution.id).first() member_role = check_member_role(request.user, institution) - if not member_role or not request.user.is_authenticated or not project.can_user_access(request.user): - return redirect('view-project', project_uuid) + if ( + not member_role + or not request.user.is_authenticated + or not project.can_user_access(request.user) + ): + return redirect("view-project", project_uuid) else: - notices = Notice.objects.filter(project=project, archived=False) + notices = Notice.objects.filter(project=project, archived=False).exclude(notice_type='open_to_collaborate') creator = ProjectCreator.objects.get(project=project) - statuses = ProjectStatus.objects.select_related('community').filter(project=project) - comments = ProjectComment.objects.select_related('sender').filter(project=project) + statuses = ProjectStatus.objects.select_related("community").filter( + project=project + ) + comments = ProjectComment.objects.select_related("sender").filter( + project=project + ) entities_notified = EntitiesNotified.objects.get(project=project) communities = Community.approved.all() - activities = ProjectActivity.objects.filter(project=project).order_by('-date') - sub_projects = Project.objects.filter(source_project_uuid=project.unique_id).values_list('unique_id', 'title') + activities = ProjectActivity.objects.filter(project=project).order_by( + "-date" + ) + sub_projects = Project.objects.filter( + source_project_uuid=project.unique_id + ).values_list("unique_id", "title") name = get_users_name(request.user) label_groups = return_project_labels_by_community(project) can_download = can_download_project(request, creator) - # for related projects list - project_ids = list(set(institution.institution_created_project.all().values_list('project__unique_id', flat=True) - .union(institution.institutions_notified.all().values_list('project__unique_id', flat=True)) - .union(institution.contributing_institutions.all().values_list('project__unique_id', flat=True)))) - project_ids_to_exclude_list = list(project.related_projects.all().values_list('unique_id', flat=True)) #projects that are currently related + # for related projects list + project_ids = list( + set( + institution.institution_created_project.all() + .values_list("project__unique_id", flat=True) + .union( + institution.institutions_notified.all().values_list( + "project__unique_id", flat=True + ) + ) + .union( + institution.contributing_institutions.all().values_list( + "project__unique_id", flat=True + ) + ) + ) + ) + project_ids_to_exclude_list = list( + project.related_projects.all().values_list("unique_id", flat=True) + ) # projects that are currently related # exclude projects that are already related project_ids = list(set(project_ids).difference(project_ids_to_exclude_list)) - projects_to_link = Project.objects.filter(unique_id__in=project_ids).exclude(unique_id=project.unique_id).order_by('-date_added').values_list('unique_id', 'title') + projects_to_link = ( + Project.objects.filter(unique_id__in=project_ids) + .exclude(unique_id=project.unique_id) + .order_by("-date_added") + .values_list("unique_id", "title") + ) project_archived = False - if ProjectArchived.objects.filter(project_uuid=project.unique_id, institution_id=institution.id).exists(): - x = ProjectArchived.objects.get(project_uuid=project.unique_id, institution_id=institution.id) + if ProjectArchived.objects.filter( + project_uuid=project.unique_id, institution_id=institution.id + ).exists(): + x = ProjectArchived.objects.get( + project_uuid=project.unique_id, institution_id=institution.id + ) project_archived = x.archived form = ProjectCommentForm(request.POST or None) - communities_list = list(chain( - project.project_status.all().values_list('community__id', flat=True), - )) + communities_list = list( + chain( + project.project_status.all().values_list( + "community__id", flat=True + ), + ) + ) if creator.community: communities_list.append(creator.community.id) - communities_ids = list(set(communities_list)) # remove duplicate ids - communities = Community.approved.exclude(id__in=communities_ids).order_by('community_name') + communities_ids = list(set(communities_list)) # remove duplicate ids + communities = Community.approved.exclude(id__in=communities_ids).order_by( + "community_name" + ) - if request.method == 'POST': - if request.POST.get('message'): + if request.method == "POST": + if request.POST.get("message"): if form.is_valid(): data = form.save(commit=False) data.project = project @@ -831,24 +1183,38 @@ def project_actions(request, pk, project_uuid): send_action_notification_to_project_contribs(project) return redirect('institution-project-actions', institution.id, project.unique_id) - elif 'notify_btn' in request.POST: + elif 'notify_btn' in request.POST: + if subscription.notification_count == 0: + messages.add_message(request, messages.ERROR, 'Your institution has reached its notification limit. ' + 'Please upgrade your subscription plan to notify more communities.') + return redirect('institution-project-actions', institution.id, project.unique_id) # Set private project to contributor view - if project.project_privacy == 'Private': - project.project_privacy = 'Contributor' + if project.project_privacy == "Private": + project.project_privacy = "Contributor" project.save() communities_selected = request.POST.getlist('selected_communities') - + notification_count = subscription.notification_count + if notification_count == -1: + count = len(communities_selected) + else: + count = min(notification_count, len(communities_selected)) # Reference ID and title for notification - title = str(institution.institution_name) + ' has notified you of a Project.' + title = ( + str(institution.institution_name) + + " has notified you of a Project." + ) - for community_id in communities_selected: + for community_id in communities_selected[:count]: # Add communities that were notified to entities_notified instance community = Community.objects.get(id=community_id) entities_notified.communities.add(community) - + # Add activity - ProjectActivity.objects.create(project=project, activity=f'{community.community_name} was notified by {name}') + ProjectActivity.objects.create( + project=project, + activity=f"{community.community_name} was notified by {name}", + ) # Adds activity to Hub Activity HubActivity.objects.create( @@ -856,18 +1222,30 @@ def project_actions(request, pk, project_uuid): action_type="Community Notified", community_id=community.id, institution_id=institution.id, - action_account_type='institution', - project_id=project.id + action_account_type="institution", + project_id=project.id, ) # Create project status, first comment and notification - ProjectStatus.objects.create(project=project, community=community, seen=False) # Creates a project status for each community - ActionNotification.objects.create(community=community, notification_type='Projects', reference_id=str(project.unique_id), sender=request.user, title=title) + ProjectStatus.objects.create( + project=project, community=community, seen=False + ) # Creates a project status for each community + ActionNotification.objects.create( + community=community, + notification_type="Projects", + reference_id=str(project.unique_id), + sender=request.user, + title=title, + ) entities_notified.save() # Create email send_email_notice_placed(request, project, community, institution) - + + # commenting this because we are not showing notification on project_action page + if subscription.notification_count > 0: + subscription.notification_count -= notification_count + subscription.save() return redirect('institution-project-actions', institution.id, project.unique_id) elif 'link_projects_btn' in request.POST: selected_projects = request.POST.getlist('projects_to_link') @@ -879,72 +1257,100 @@ def project_actions(request, pk, project_uuid): project_to_add.related_projects.add(project) project_to_add.save() - activities.append(ProjectActivity(project=project, activity=f'Project "{project_to_add.title}" was connected to Project by {name} | {institution.institution_name}')) - activities.append(ProjectActivity(project=project_to_add, activity=f'Project "{project.title}" was connected to Project by {name} | {institution.institution_name}')) - + activities.append( + ProjectActivity( + project=project, + activity=f'Project "{project_to_add.title}" was connected to Project by {name} | {institution.institution_name}', + ) + ) + activities.append( + ProjectActivity( + project=project_to_add, + activity=f'Project "{project.title}" was connected to Project by {name} | {institution.institution_name}', + ) + ) + ProjectActivity.objects.bulk_create(activities) project.save() - return redirect('institution-project-actions', institution.id, project.unique_id) - - elif 'delete_project' in request.POST: - return redirect('inst-delete-project', institution.id, project.unique_id) - - elif 'remove_contributor' in request.POST: + return redirect( + "institution-project-actions", institution.id, project.unique_id + ) + + elif "delete_project" in request.POST: + return redirect( + "inst-delete-project", institution.id, project.unique_id + ) + + elif "remove_contributor" in request.POST: contribs = ProjectContributors.objects.get(project=project) contribs.institutions.remove(institution) contribs.save() - return redirect('institution-project-actions', institution.id, project.unique_id) + return redirect( + "institution-project-actions", institution.id, project.unique_id + ) context = { - 'member_role': member_role, - 'institution': institution, - 'project': project, - 'notices': notices, - 'creator': creator, - 'form': form, - 'communities': communities, - 'statuses': statuses, - 'comments': comments, - 'activities': activities, - 'project_archived': project_archived, - 'sub_projects': sub_projects, - 'projects_to_link': projects_to_link, - 'label_groups': label_groups, - 'can_download': can_download, + "member_role": member_role, + "institution": institution, + "project": project, + "notices": notices, + "creator": creator, + "form": form, + "communities": communities, + "statuses": statuses, + "comments": comments, + "activities": activities, + "project_archived": project_archived, + "sub_projects": sub_projects, + "projects_to_link": projects_to_link, + "label_groups": label_groups, + "can_download": can_download, + "subscription": subscription, } - return render(request, 'institutions/project-actions.html', context) + return render(request, "institutions/project-actions.html", context) except: raise Http404() -@login_required(login_url='login') -@member_required(roles=['admin', 'editor']) + +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) def archive_project(request, pk, project_uuid): - if not ProjectArchived.objects.filter(institution_id=pk, project_uuid=project_uuid).exists(): - ProjectArchived.objects.create(institution_id=pk, project_uuid=project_uuid, archived=True) + if not ProjectArchived.objects.filter( + institution_id=pk, project_uuid=project_uuid + ).exists(): + ProjectArchived.objects.create( + institution_id=pk, project_uuid=project_uuid, archived=True + ) else: - archived_project = ProjectArchived.objects.get(institution_id=pk, project_uuid=project_uuid) + archived_project = ProjectArchived.objects.get( + institution_id=pk, project_uuid=project_uuid + ) if archived_project.archived: archived_project.archived = False else: archived_project.archived = True archived_project.save() - return redirect('institution-project-actions', pk, project_uuid) + return redirect("institution-project-actions", pk, project_uuid) -@login_required(login_url='login') -@member_required(roles=['admin', 'editor']) + +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) +@transaction.atomic def delete_project(request, pk, project_uuid): institution = get_institution(pk) project = Project.objects.get(unique_id=project_uuid) + subscription = Subscription.objects.get(institution=institution) - if ActionNotification.objects.filter(reference_id=project.unique_id).exists(): - for notification in ActionNotification.objects.filter(reference_id=project.unique_id): - notification.delete() - + delete_action_notification(project.unique_id) project.delete() + + if subscription.project_count >= 0: + subscription.project_count +=1 + subscription.save() return redirect('institution-projects', institution.id) -@login_required(login_url='login') -@member_required(roles=['admin', 'editor']) +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) def unlink_project(request, pk, target_proj_uuid, proj_to_remove_uuid): institution = get_institution(pk) target_project = Project.objects.get(unique_id=target_proj_uuid) @@ -954,55 +1360,332 @@ def unlink_project(request, pk, target_proj_uuid, proj_to_remove_uuid): target_project.save() project_to_remove.save() name = get_users_name(request.user) - ProjectActivity.objects.create(project=project_to_remove, activity=f'Connection was removed between Project "{project_to_remove}" and Project "{target_project}" by {name}') - ProjectActivity.objects.create(project=target_project, activity=f'Connection was removed between Project "{target_project}" and Project "{project_to_remove}" by {name}') - return redirect('institution-project-actions', institution.id, target_project.unique_id) - -@login_required(login_url='login') -@member_required(roles=['admin', 'editor', 'viewer']) + ProjectActivity.objects.create( + project=project_to_remove, + activity=f'Connection was removed between Project "{project_to_remove}" and Project "{target_project}" by {name}', + ) + ProjectActivity.objects.create( + project=target_project, + activity=f'Connection was removed between Project "{target_project}" and Project "{project_to_remove}" by {name}', + ) + return redirect( + "institution-project-actions", institution.id, target_project.unique_id + ) + + +@login_required(login_url="login") +@member_required(roles=["admin", "editor", "viewer"]) def connections(request, pk): institution = get_institution(pk) member_role = check_member_role(request.user, institution) institutions = Institution.objects.none() - researcher_ids = institution.contributing_institutions.exclude(researchers__id=None).values_list('researchers__id', flat=True) - community_ids = institution.contributing_institutions.exclude(communities__id=None).values_list('communities__id', flat=True) - - communities = Community.objects.select_related('community_creator').prefetch_related('admins', 'editors', 'viewers').filter(id__in=community_ids) - researchers = Researcher.objects.select_related('user').filter(id__in=researcher_ids) - - project_ids = institution.contributing_institutions.values_list('project__unique_id', flat=True) - contributors = ProjectContributors.objects.filter(project__unique_id__in=project_ids) - for c in contributors: - institutions = c.institutions.select_related('institution_creator').prefetch_related('admins', 'editors', 'viewers').exclude(id=institution.id) + # Researcher contributors + researcher_ids = institution.contributing_institutions.exclude( + researchers__id=None + ).values_list("researchers__id", flat=True) + researchers = Researcher.objects.select_related("user").filter( + id__in=researcher_ids + ) + + # Community contributors + community_ids = institution.contributing_institutions.exclude( + communities__id=None + ).values_list("communities__id", flat=True) + communities = ( + Community.objects.select_related("community_creator") + .prefetch_related("admins", "editors", "viewers") + .filter(id__in=community_ids) + ) + + # Institution contributors + project_ids = institution.contributing_institutions.values_list( + "project__unique_id", flat=True + ) + contributors = ProjectContributors.objects.filter( + project__unique_id__in=project_ids + ).values_list("institutions__id", flat=True) + institutions = ( + Institution.objects.select_related("institution_creator") + .prefetch_related("admins", "editors", "viewers") + .filter(id__in=contributors) + .exclude(id=institution.id) + ) context = { - 'member_role': member_role, - 'institution': institution, - 'communities': communities, - 'researchers': researchers, - 'institutions': institutions, + "member_role": member_role, + "institution": institution, + "communities": communities, + "researchers": researchers, + "institutions": institutions, } - return render(request, 'institutions/connections.html', context) + return render(request, "institutions/connections.html", context) + + +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) +def connect_service_provider(request, pk): + try: + envi = dev_prod_or_local(request.get_host()) + institution = get_institution(pk) + member_role = check_member_role(request.user, institution) + if request.method == "GET": + service_providers = get_certified_service_providers(request) + connected_service_providers_ids = ServiceProviderConnections.objects.filter( + institutions=institution + ).values_list('service_provider', flat=True) + connected_service_providers = service_providers.filter(id__in=connected_service_providers_ids) + other_service_providers = service_providers.exclude(id__in=connected_service_providers_ids) + + elif request.method == "POST": + if "connectServiceProvider" in request.POST: + if institution.is_subscribed: + service_provider_id = request.POST.get('connectServiceProvider') + connection_reference_id = f"{service_provider_id}:{institution.id}_i" + + if ServiceProviderConnections.objects.filter( + service_provider=service_provider_id).exists(): + # Connect institution to existing Service Provider connection + sp_connection = ServiceProviderConnections.objects.get( + service_provider=service_provider_id + ) + sp_connection.institutions.add(institution) + sp_connection.save() + else: + # Create new Service Provider Connection and add institution + service_provider = ServiceProvider.objects.get(id=service_provider_id) + sp_connection = ServiceProviderConnections.objects.create( + service_provider = service_provider + ) + sp_connection.institutions.add(institution) + sp_connection.save() + + # Delete instances of disconnect Notifications + delete_action_notification(connection_reference_id) + + # Send notification of connection to Service Provider + target_org = sp_connection.service_provider + title = f"{institution.institution_name} has connected to {target_org.name}" + send_simple_action_notification( + None, target_org, title, "Connections", connection_reference_id + ) + else: + messages.add_message( + request, messages.ERROR, + 'Your account must be subscribed to connect to Service Providers.' + ) + + elif "disconnectServiceProvider" in request.POST: + service_provider_id = request.POST.get('disconnectServiceProvider') + connection_reference_id = f"{service_provider_id}:{institution.id}_i" + + sp_connection = ServiceProviderConnections.objects.get( + service_provider=service_provider_id + ) + sp_connection.institutions.remove(institution) + sp_connection.save() + + # Delete instances of the connection notification + delete_action_notification(connection_reference_id) + + # Send notification of disconneciton to Service Provider + target_org = sp_connection.service_provider + title = f"{institution.institution_name} has been disconnected from " \ + f"{target_org.name}" + send_simple_action_notification( + None, target_org, title, "Connections", connection_reference_id + ) + + return redirect("institution-connect-service-provider", institution.id) + + context = { + 'member_role': member_role, + 'institution': institution, + 'other_service_providers': other_service_providers, + 'connected_service_providers': connected_service_providers, + 'envi': envi + } + return render(request, 'account_settings_pages/_connect-service-provider.html', context) + + except: + raise Http404() + +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) +def account_preferences(request, pk): + try: + institution = get_institution(pk) + member_role = check_member_role(request.user, institution) + + if request.method == "POST": + + # Set Show/Hide account in Service Provider connections + if request.POST.get('show_sp_connection') == 'on': + institution.show_sp_connection = True + + elif request.POST.get('show_sp_connection') == None: + institution.show_sp_connection = False + + # Set project privacy settings for Service Provider connections + institution.sp_privacy = request.POST.get('sp_privacy') + + institution.save() + + messages.add_message( + request, messages.SUCCESS, 'Your preferences have been updated!' + ) + + return redirect("preferences-institution", institution.id) + + context = { + 'member_role': member_role, + 'institution': institution, + } + return render(request, 'account_settings_pages/_preferences.html', context) + + except Institution.DoesNotExist: + raise Http404() + @force_maintenance_mode_off def embed_otc_notice(request, pk): - layout = request.GET.get('lt') - lang = request.GET.get('lang') - align = request.GET.get('align') + layout = request.GET.get("lt") + lang = request.GET.get("lang") + align = request.GET.get("align") institution = Institution.objects.get(id=pk) otc_notices = OpenToCollaborateNoticeURL.objects.filter(institution=institution) - + context = { - 'layout' : layout, - 'lang' : lang, - 'align' : align, - 'otc_notices' : otc_notices, - 'institution' : institution + "layout": layout, + "lang": lang, + "align": align, + "otc_notices": otc_notices, + "institution": institution, } - response = render(request, 'partials/_embed.html', context) - response['Content-Security-Policy'] = 'frame-ancestors https://*' + response = render(request, "partials/_embed.html", context) + response["Content-Security-Policy"] = "frame-ancestors https://*" return response + +# Create API Key +@login_required(login_url="login") +@member_required(roles=["admin"]) +@transaction.atomic +def api_keys(request, pk): + institution = get_institution(pk) + member_role = check_member_role(request.user, institution) + remaining_api_key_count = 0 + envi = dev_prod_or_local(request.get_host()) + + try: + if institution.is_subscribed: + subscription = Subscription.objects.get(institution=institution) + remaining_api_key_count = subscription.api_key_count + + if request.method == 'GET': + form = APIKeyGeneratorForm(request.GET or None) + account_keys = AccountAPIKey.objects.filter(institution=institution).exclude( + Q(expiry_date__lt=timezone.now()) | Q(revoked=True) + ).values_list("prefix", "name", "encrypted_key") + + elif request.method == "POST": + if "generate_api_key" in request.POST: + if institution.is_subscribed and remaining_api_key_count == 0: + messages.add_message(request, messages.ERROR, 'Your institution has reached its API Key limit. ' + 'Please upgrade your subscription plan to create more API Keys.') + return redirect("institution-api-key", institution.id) + form = APIKeyGeneratorForm(request.POST) + + if institution.is_subscribed: + if form.is_valid(): + api_key, key = AccountAPIKey.objects.create_key( + name = form.cleaned_data["name"], + institution_id = institution.id + ) + prefix = key.split(".")[0] + encrypted_key = encrypt_api_key(key) + AccountAPIKey.objects.filter(prefix=prefix).update(encrypted_key=encrypted_key) + + if subscription.api_key_count > 0: + subscription.api_key_count -= 1 + subscription.save() + else: + messages.add_message(request, messages.ERROR, 'Please enter a valid API Key name.') + return redirect("institution-api-key", institution.id) + + else: + messages.add_message(request, messages.ERROR, 'Your institution is not subscribed. ' + 'You must have an active subscription to create more API Keys.') + return redirect("institution-api-key", institution.id) + + return redirect("institution-api-key", institution.id) + + elif "delete_api_key" in request.POST: + prefix = request.POST['delete_api_key'] + api_key = AccountAPIKey.objects.filter(prefix=prefix) + api_key.delete() + + if institution.is_subscribed and subscription.api_key_count >= 0: + subscription.api_key_count +=1 + subscription.save() + + return redirect("institution-api-key", institution.id) + + context = { + "institution" : institution, + "form" : form, + "account_keys" : account_keys, + "member_role" : member_role, + "remaining_api_key_count" : remaining_api_key_count, + "envi": envi, + } + return render(request, 'account_settings_pages/_api-keys.html', context) + except: + raise Http404() + +@login_required(login_url="login") +def create_institution_subscription(request, pk): + try: + institute = get_institution(pk) + env = dev_prod_or_local(request.get_host()) + initial_data = { + "first_name": request.user._wrapped.first_name, + "last_name": request.user._wrapped.last_name, + "email": request.user._wrapped.email, + "organization_name": institute.institution_name, + } + subscription_form = SubscriptionForm(initial=initial_data) + subscription_form.fields['organization_name'].widget.attrs.update({"class": "w-100 readonly-input"}) + + if request.method == "POST": + if validate_recaptcha(request): + mutable_post_data = request.POST.copy() + subscription_data = { + "first_name": request.POST['first_name'], + "last_name": request.POST['last_name'], + "email": request.POST['email'], + "account_type": "institution_account", + "inquiry_type": request.POST['inquiry_type'], + "organization_name": request.POST['organization_name'], + } + + mutable_post_data.update(subscription_data) + subscription_form = SubscriptionForm(mutable_post_data) + if subscription_form.is_valid() and env != 'SANDBOX': + handle_confirmation_and_subscription(request, subscription_form, institute, env) + return redirect('dashboard') + else: + messages.add_message( + request, messages.ERROR, "Something went wrong. Please Try again later.", + ) + return redirect('dashboard') + return render( + request, "account_settings_pages/_subscription-form.html", { + "subscription_form": subscription_form, + 'institution': institute + } + ) + except Institution.DoesNotExist: + return render(request, '404.html', status=404) \ No newline at end of file diff --git a/localcontexts/admin.py b/localcontexts/admin.py index dac25acd2..59b70963a 100644 --- a/localcontexts/admin.py +++ b/localcontexts/admin.py @@ -2,48 +2,53 @@ import csv import itertools from datetime import datetime, timedelta, timezone +from typing import Tuple +from django.db.models.base import Model as Model from django.db.models.functions import Extract, Concat from django.db.models import Count, Q, Value, F, CharField, Case, When from django.contrib import admin from django.contrib.admin.models import LogEntry from django.urls import path from django.utils.translation import gettext as _ -from django.utils.html import format_html, format_html_join +from django.utils.html import format_html from django.utils.safestring import mark_safe from django.apps import apps from django.template.response import TemplateResponse from django.http import Http404, HttpRequest, HttpResponse -from django.contrib import admin from django.contrib.admin.widgets import AdminFileWidget from django.contrib.auth.admin import GroupAdmin, UserAdmin from django.contrib.auth.models import Group, User from django.db import models -from django.db.models import Case, CharField, Count, F, Q, Value, When -from django.db.models.functions import Concat, Extract -from django.http import Http404, HttpResponse from django.shortcuts import redirect, render -from django.template.response import TemplateResponse -from django.urls import path -from django.utils.html import format_html -from django.utils.safestring import mark_safe -from django.utils.translation import gettext as _ + +from accounts.models import Profile, UserAffiliation, SignUpInvitation, Subscription from django_apscheduler.models import DjangoJob, DjangoJobExecution -from rest_framework_api_key.admin import APIKey, APIKeyModelAdmin +from rest_framework_api_key.admin import APIKeyModelAdmin +from api.models import AccountAPIKey +from django.contrib.auth.admin import UserAdmin, GroupAdmin +from django.contrib.auth.models import Group, User +from django.contrib.admin.widgets import AdminFileWidget import helpers -from accounts.models import (InactiveUser, Profile, SignUpInvitation, UserAffiliation) +from accounts.models import ( + InactiveUser, Profile, SignUpInvitation, UserAffiliation, ServiceProviderConnections +) from accounts.utils import get_users_name from bclabels.models import BCLabel from communities.forms import CommunityModelForm from communities.models import Community, InviteMember, JoinRequest +from helpers.models import * +from helpers.utils import encrypt_api_key from helpers.models import ( CollectionsCareNoticePolicy, EntitiesNotified, LabelTranslation, LabelTranslationVersion, LabelVersion, Notice, NoticeDownloadTracker, NoticeTranslation, OpenToCollaborateNoticeURL, ProjectStatus ) from institutions.models import Institution -from notifications.models import ActionNotification, UserNotification +from researchers.utils import is_user_researcher +from serviceproviders.models import ServiceProvider +from notifications.models import UserNotification, ActionNotification from projects.forms import ProjectModelForm from projects.models import ( Project, ProjectActivity, ProjectArchived, ProjectContributors, ProjectCreator, ProjectNote, @@ -371,7 +376,7 @@ class AccountTypeFilter(admin.SimpleListFilter): def lookups(self, request, model_admin): return [ ('institution', 'Institution'), ('researcher', 'Researcher'), - ('community', 'Community') + ('community', 'Community'), ('service_provider', 'Service Provider') ] def queryset(self, request, queryset): @@ -411,6 +416,19 @@ def queryset(self, request, queryset): except: # noqa return queryset.none() + elif self.value() == "service_provider": + try: + qs = queryset.distinct().filter(service_provider_id__isnull=False) + return qs + except: + return queryset.none() + + elif self.value() == "service_provider": + try: + qs = queryset.distinct().filter(service_provider_id__isnull=False) + return qs + except: + return queryset.none() class PrivacyTypeFilter(admin.SimpleListFilter): title = 'Privacy Type' @@ -527,7 +545,6 @@ class DateRangeFilter(admin.SimpleListFilter): def choices(self, changelist): choices = list(super().choices(changelist)) - print(choices) choices[0]['display'] = _('last 30 Days') return [choices[2], choices[0], choices[1]] @@ -665,12 +682,12 @@ def changelist_view(self, request, extra_context=None): ).values('id', 'account_name', 'days_count', 'account_type') inactive_institutions = Institution.objects.filter( - is_approved=False, created__lte=datetime.now(tz=timezone.utc) - timedelta(days=90) - ).annotate( - days_count=datetime.now(tz=timezone.utc) - F('created'), - account_name=F('institution_name'), - account_type=Value('Institution', output_field=CharField()) - ).values('id', 'account_name', 'days_count', 'account_type') + is_subscribed = False, + created__lte = datetime.now(tz = timezone.utc) - timedelta(days = 90)).annotate( + days_count = datetime.now(tz = timezone.utc) - F('created'), + account_name = F('institution_name'), + account_type = Value('Institution', output_field=CharField()) + ).values('id', 'account_name', 'days_count', 'account_type') inactive_communities = Community.objects.filter( is_approved=False, created__lte=datetime.now(tz=timezone.utc) - timedelta(days=90) @@ -716,7 +733,8 @@ class OTCLinksAdmin(admin.ModelAdmin, ExportCsvMixin): list_display = ('name', 'view', 'added_by', 'datetime') search_fields = ( 'institution__institution_name', 'researcher__user__username', - 'researcher__user__first_name', 'researcher__user__last_name', 'name' + 'researcher__user__first_name', 'researcher__user__last_name', 'name', + 'service_provider__name' ) ordering = ('-added', ) list_filter = (AccountTypeFilter, ) @@ -735,10 +753,14 @@ def added_by(self, obj): account_id = obj.institution_id account_url = 'institutions/institution' account_name = obj.institution.institution_name - else: + elif obj.researcher_id: account_id = obj.researcher_id account_url = 'researchers/researcher' account_name = get_users_name(obj.researcher.user) + elif obj.service_provider_id: + account_id = obj.service_provider_id + account_url = 'serviceproviders/serviceprovider' + account_name = obj.service_provider.name return format_html( '{} ', account_url, account_id, account_name @@ -1409,16 +1431,34 @@ class ProfileAdmin(admin.ModelAdmin): readonly_fields = ('api_key', ) +class SubscriptionsAdmin(admin.ModelAdmin): + list_display = ('institution', 'community', 'researcher', 'date_last_updated') + admin_site.register(Profile, ProfileAdmin) admin_site.register(UserAffiliation) admin_site.register(SignUpInvitation, SignUpInvitationAdmin) +admin_site.register(Subscription, SubscriptionsAdmin) # admin_site.unregister(User) admin_site.register(User, UserAdminCustom) # API KEYS ADMIN -admin_site.register(APIKey, APIKeyModelAdmin) - +class AccountAPIKeyAdmin(APIKeyModelAdmin): + def get_readonly_fields(self, request: HttpRequest, obj: Model = None) -> Tuple[str, ...]: + readonly_fields = super().get_readonly_fields(request, obj) + try: + if obj.encrypted_key: + readonly_fields = readonly_fields + ('encrypted_key',) + except: + pass + return readonly_fields + + def save_model(self, request, obj, form, change): + if obj.encrypted_key: + obj.encrypted_key = encrypt_api_key(obj.encrypted_key) + return super().save_model(request, obj, form, change) + +admin_site.register(AccountAPIKey, AccountAPIKeyAdmin) # AUTH ADMIN class MyGroupAdmin(GroupAdmin): @@ -1459,7 +1499,7 @@ class BCLabelAdmin(admin.ModelAdmin): class CommunityAdmin(admin.ModelAdmin): form = CommunityModelForm list_display = ( - 'community_name', 'community_creator', 'contact_name', 'contact_email', 'is_approved', + 'community_name', 'community_creator', 'contact_name', 'contact_email', 'is_member', 'created', 'country' ) search_fields = ( @@ -1503,6 +1543,11 @@ class NoticeAdmin(admin.ModelAdmin): 'institution__institution_name' ) + def formfield_for_choice_field(self, db_field, request, **kwargs): + if db_field.name == 'notice_type': + kwargs['choices'] = [choice for choice in db_field.choices if choice[0] != 'open_to_collaborate'] + return super().formfield_for_choice_field(db_field, request, **kwargs) + class OpenToCollaborateNoticeURLAdmin(admin.ModelAdmin): list_display = ('institution', 'researcher', 'name', 'url', 'added') @@ -1550,11 +1595,6 @@ class LabelTranslationVersionAdmin(admin.ModelAdmin): ) -class ProjectStatusAdmin(admin.ModelAdmin): - list_display = ('project', 'community', 'seen', 'status') - search_fields = ('project__title', 'community__community_name') - - class NoticeDownloadTrackerAdmin(admin.ModelAdmin): list_display = ( 'user', 'institution', 'researcher', 'collections_care_notices', @@ -1575,7 +1615,6 @@ class NoticeTranslationAdmin(admin.ModelAdmin): list_display = ('notice', 'notice_type', 'language') -admin_site.register(ProjectStatus, ProjectStatusAdmin) admin_site.register(Notice, NoticeAdmin) admin_site.register(LabelVersion, LabelVersionAdmin) admin_site.register(LabelTranslationVersion, LabelTranslationVersionAdmin) @@ -1591,16 +1630,12 @@ class NoticeTranslationAdmin(admin.ModelAdmin): class InstitutionAdmin(admin.ModelAdmin): list_display = ( 'institution_name', 'institution_creator', 'contact_name', 'contact_email', - 'is_approved', 'is_ror', 'created', 'country' + 'is_subscribed', 'is_ror', 'created', 'country' ) search_fields = ( - 'institution_name', - 'institution_creator__username', - 'contact_name', - 'contact_email', + 'institution_name', 'institution_creator__username', 'contact_name', 'contact_email', ) - admin_site.register(Institution, InstitutionAdmin) @@ -1615,7 +1650,7 @@ class UserNotificationAdmin(admin.ModelAdmin): class ActionNotificationAdmin(admin.ModelAdmin): list_display = ( 'sender', 'community', 'institution', 'researcher', 'notification_type', 'title', - 'created' + 'service_provider', 'created' ) @@ -1639,7 +1674,6 @@ class ProjectAdmin(admin.ModelAdmin): 'project_contact_email', ) - class ProjectContributorsAdmin(admin.ModelAdmin): list_display = ('project', ) search_fields = ('project__title', ) @@ -1669,6 +1703,10 @@ class ProjectArchivedAdmin(admin.ModelAdmin): class ProjectNoteAdmin(admin.ModelAdmin): list_display = ('project', 'community') +class ProjectStatusAdmin(admin.ModelAdmin): + list_display = ('project', 'community', 'seen', 'status') + search_fields = ('project__title', 'community__community_name') + admin_site.register(Project, ProjectAdmin) admin_site.register(ProjectContributors, ProjectContributorsAdmin) @@ -1677,6 +1715,7 @@ class ProjectNoteAdmin(admin.ModelAdmin): admin_site.register(ProjectActivity, ProjectActivityAdmin) admin_site.register(ProjectArchived, ProjectArchivedAdmin) admin_site.register(ProjectNote, ProjectNoteAdmin) +admin_site.register(ProjectStatus, ProjectStatusAdmin) # RESEARCHERS ADMIN @@ -1742,3 +1781,25 @@ class InactiveUserAdmin(admin.ModelAdmin): admin_site.register(InactiveUser, InactiveUserAdmin) + +# SERVICE PROVIDER ADMIN +class ServiceProviderAdmin(admin.ModelAdmin): + list_display = ( + 'name', 'account_creator', 'contact_name', 'contact_email', 'is_certified', 'created', + ) + search_fields = ('name', 'account_creator__username', 'contact_name', 'contact_email',) + + def save_model(self, request, obj, form, change): + if obj.is_certified and 'is_certified' in form.changed_data and not obj.certified_by: + obj.certified_by = request.user + obj.save() + + +class ServiceProviderConnectionsAdmin(admin.ModelAdmin): + list_display = ( + 'service_provider', + ) + search_fields = ('service_provider',) + +admin_site.register(ServiceProvider, ServiceProviderAdmin) +admin_site.register(ServiceProviderConnections, ServiceProviderConnectionsAdmin) \ No newline at end of file diff --git a/localcontexts/settings.py b/localcontexts/settings.py index f8d649cc9..466f99878 100644 --- a/localcontexts/settings.py +++ b/localcontexts/settings.py @@ -65,6 +65,7 @@ 'api', 'helpers', 'notifications', + 'serviceproviders', 'django.contrib.admin', 'django.contrib.auth', @@ -81,6 +82,8 @@ 'rest_framework', 'rest_framework_api_key', + 'drf_spectacular', + 'django_filters', 'corsheaders', 'debug_toolbar', 'dbbackup', @@ -214,6 +217,11 @@ MAILGUN_V4_BASE_URL = os.environ.get('MAILGUN_V4_BASE_URL') MAILGUN_TEMPLATE_URL = os.environ.get('MAILGUN_TEMPLATE_URL') +# Sales force creds +SALES_FORCE_CLIENT_ID = os.environ.get('SALES_FORCE_CLIENT_ID') +SALES_FORCE_SECRET_ID = os.environ.get('SALES_FORCE_SECRET_ID') +SALES_FORCE_BASE_URL = os.environ.get('SALES_FORCE_BASE_URL') + # Config for sending out emails EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' EMAIL_HOST = os.environ.get('EMAIL_HOST') @@ -223,6 +231,8 @@ DEFAULT_FROM_EMAIL = os.environ.get('DEFAULT_FROM_EMAIL') EMAIL_USE_TLS = True +SF_VALID_USER_IDS = os.environ.get('SF_VALID_USER_IDS') + # Static files (CSS, JavaScript, Images) # https://docs.djangoproject.com/en/3.0/howto/static-files/ @@ -240,11 +250,35 @@ API_KEY_CUSTOM_HEADER = "HTTP_X_API_KEY" REST_FRAMEWORK = { + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.NamespaceVersioning', 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 10 } +SPECTACULAR_SETTINGS = { + 'TITLE': 'Local Contexts Hub API', + 'DESCRIPTION': 'The Local Contexts Hub enables the customization of Labels and the application of Notices directly to Indigenous data. The Hub works in tandem with already existing information/collections management systems and tools, generating Labels and Notices.', + 'SERVE_INCLUDE_SCHEMA': False, + 'AUTHENTICATION_WHITELIST': ['api.versioned.v2.views.APIKeyAuthentication'], + 'SCHEMA_PATH_PREFIX': r'/api/v[0-9]/', + 'SCHEMA_PATH_PREFIX_TRIM': True, + 'ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE': False, + 'CONTACT': { + 'name': 'Local Contexts Tech Team', + 'email': 'tech@localcontexts.org' + }, + 'TOS': 'https://localcontexts.org/terms-conditions/', + 'LICENSE': { + 'name': 'MIT License', + 'url': 'https://github.com/localcontexts/localcontextshub?tab=License-1-ov-file#readme' + }, + 'EXTERNAL_DOCS': { + 'description': 'Local Contexts GitHub Documentation', + 'url': 'https://github.com/localcontexts/localcontextshub' + }, +} + # Session expires after an hour of inactivity. # Note: this will UPDATE the db on every request. SESSION_COOKIE_AGE = 3600 diff --git a/localcontexts/static/css/dashboard.css b/localcontexts/static/css/dashboard.css index 4352b4275..5053a93cd 100644 --- a/localcontexts/static/css/dashboard.css +++ b/localcontexts/static/css/dashboard.css @@ -3,11 +3,14 @@ padding: 20px; } +.dash-driver, .dashcard, .dashcard-alerts { + margin: 8px auto; + width: 1280px; +} + .dash-driver, .dashcard { - margin: 8px auto 8px auto; border-radius: 5px; box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.25); - width: 1280px; background-color: #fff; } @@ -29,15 +32,30 @@ .dashcard-img-container, .profile-img { width: 286px; - height: 160.88px; + height: 160px; + position: relative; } .profile-img, .researcher-img-container img { object-fit: cover; } .researcher-img-container, .researcher-img-container img { - width: 190px; - height: 190px; + width: 160px; + height: 160px; +} + +.certified-overlay { + position: absolute; + width: inherit; + bottom:0; + height: 55px; +} + +.certified-overlay img { + margin-left: 5px; + margin-bottom: 5px; + position: absolute; + bottom: 0; } .dashcard-text-container { min-width: 51%;} @@ -51,14 +69,15 @@ } .dashcard-subheader { - margin-bottom: 0; + margin-top: 8px; + margin-bottom: 8px; font-weight: bold; color: #333; } .dashcard-description { - margin-top: 5px; - margin-bottom: 0; + margin-top: 0px; + margin-bottom: 0px; font-style: normal; color: #333; width: 95%; @@ -81,6 +100,7 @@ justify-content: flex-end; font-size: 14px; font-weight: bold; + margin-left: auto; } /* SETTINGS */ @@ -92,10 +112,9 @@ border-right: 1px solid #000; padding-right: 15px; margin-right: 1%; - margin-bottom: 2%; } -.side-nav-a { +.side-nav-container a { display: block; width: 100%; } @@ -115,6 +134,12 @@ margin-bottom: 5px; } +.side-nav-item .icon { + width: fit-content; + height: fit-content; + margin: auto; +} + .side-nav-item i { display: block; font-size: 45px; @@ -126,7 +151,6 @@ border-radius: 4px; } -.side-nav-item > div:nth-of-type(1) { width: 25%;} .side-nav-item > div:nth-of-type(2) { margin-left: 5px; padding-right: 5px; @@ -191,4 +215,40 @@ height: 91px; } +.subscription-details { + border: 1px solid #E0E1E1; + border-radius: 4px; + padding: 10px; + background-color: #F8FAFF; +} + +.subscription-details > div { + margin-right: 10px; + flex-grow: 1; +} + +.subscription-details h5, +.subscription-details p { + margin-top: 0; + margin-bottom: 0; +} + +.subscription-activity > div { + margin-right: 10px; + padding-left: 10px; +} + +.subscription-activity h5, +.subscription-activity p { + margin-top: 0; + margin-bottom: 0; +} + +.vl { + border-left: 1px solid #E0E1E1; + height: 100%; +} + +.margin-5 { margin: 5px; } + .set-height-empty-dash { height: 200px; } \ No newline at end of file diff --git a/localcontexts/static/css/main.css b/localcontexts/static/css/main.css index d7edc290d..d28ecbaf5 100644 --- a/localcontexts/static/css/main.css +++ b/localcontexts/static/css/main.css @@ -100,6 +100,41 @@ select { input[name=contributors] { width: 416px; } +/* Toggles */ +.toggle { + appearance: none; + position: relative; + display: inline-block; + width: 55px; + height: 30px; + padding: 3px; + border: none; + cursor: pointer; + border-radius: 15.5px; + overflow: hidden; + background-color: #6E6E6E; + transition: background ease 0.3s; +} + +.toggle::before { + content: ""; + display: block; + z-index: 2; + width: 24px; + height: 24px; + background: #fff; + border-radius: 14.5px; + transition: transform cubic-bezier(0.7, 1.5, 0.7, 0.9) 0.3s; +} + +.toggle:checked { + background-color: #007385; +} + +.toggle:checked:before { + transform: translateX(24px); +} + /* Project Searchbar */ .searchbar-divider { @@ -164,20 +199,20 @@ textarea { font-size: 15px; } -input[type=checkbox] { +input[type=checkbox]:not(.toggle) { border: 2px solid #A09D9D; border-radius: 5px; min-width: 20px; max-width: 20px; } -input[type=checkbox]:checked { +input[type=checkbox]:checked:not(.toggle) { background-color: #007385; border: 2px solid #007385; border-radius: 5px; } -input:focus { +input:focus:not(.toggle) { background: #F3FDFF; border: 1px solid #007385; box-shadow: 0px 0px 1px 0.5px #007385; @@ -234,6 +269,7 @@ input:focus { .m-0 { margin: 0 !important; } .m-1p { margin: 1%; } .m-2p { margin: 2%; } +.m-31p { margin: 31%; } .m-55p { margin: 55%; } .m-5 { margin: 5px; } .m-8 { margin: 8px; } @@ -245,6 +281,7 @@ input:focus { .mt-0 { margin-top: 0; } .mt-1p { margin-top: 1%; } .mt-2p { margin-top: 2%; } +.mt-31p { margin-top: 31%; } .mt-55p { margin-top: 55%; } .mt-5 { margin-top: 5px; } .mt-8 { margin-top: 8px; } @@ -256,6 +293,7 @@ input:focus { .mb-0 { margin-bottom: 0; } .mb-1p { margin-bottom: 1%; } .mb-2p { margin-bottom: 2%; } +.mb-31p { margin-bottom: 31%; } .mb-55p { margin-bottom: 55%; } .mb-5 { margin-bottom: 5px; } .mb-8 { margin-bottom: 8px; } @@ -267,6 +305,7 @@ input:focus { .mr-0 { margin-right: 0 !important; } .mr-1p { margin-right: 1%; } .mr-2p { margin-right: 2%; } +.mr-31p { margin-right: 31%; } .mr-55p { margin-right: 55%; } .mr-5 { margin-right: 5px; } .mr-8 { margin-right: 8px; } @@ -278,6 +317,7 @@ input:focus { .ml-0 { margin-left: 0; } .ml-1p { margin-left: 1%; } .ml-2p { margin-left: 2%; } +.ml-31p { margin-left: 31%; } .ml-55p { margin-left: 55%; } .ml-5 { margin-left: 5px; } .ml-8 { margin-left: 8px; } @@ -287,6 +327,7 @@ input:focus { /* PADDING */ +.pad-16 { padding: 16px; } .no-top-pad { padding-top: 0; } .no-bottom-pad { padding-bottom: 0px; } .pad-top-1 { padding-top: 1%; } @@ -331,13 +372,19 @@ input:focus { .w-20 { width: 20%; } .w-25 { width: 25%; } .w-30 { width: 30%; } +.w-35 { width: 35%; } .w-40 { width: 40%; } +.w-45 { width: 45%; } .w-50 { width: 50%; } +.w-55 { width: 55%; } .w-60 { width: 60%; } +.w-65 { width: 65%; } .w-70 { width: 70%; } .w-75 { width: 75%; } .w-80 { width: 80%; } +.w-85 { width: 85%; } .w-90 { width: 90%; } +.w-95 { width: 95%; } .w-100 { width: 100%; } /* GAP */ @@ -712,46 +759,6 @@ visibility: visible; width: 322px; } -/* Count cards on dashcards on profiles */ -.stats-card-container { - min-width: 275px; - margin: 10px; -} - -.stats-card { - width: 100%; - height: 35px; - margin-top: 5px; - margin-bottom: 5px; - align-items: center; - color: white; - padding: 16px 8px; - border-radius: 5px; -} - -.stats-card:first-child { -background-color: #007385; -} - -.stats-card:nth-child(2) { - background-color: #5AA497; -} - -.stats-card:nth-child(3) { - background-color: #EF6C00; -} - -.stats-card p { - font-size: 24px; - line-height: 28px; - margin: 0; -} - -.stats-card span { - font-size: 16px; - line-height: 20px; -} - /* SUBNAV */ .comm-nav { margin: 6px auto; @@ -1197,6 +1204,10 @@ i[data-tooltip]:hover:after { transition: opacity 0.3s; } +.left-tooltip { + margin-left: -286px !important; +} + .tooltiptext { padding: 10px; display: inline-block; @@ -1216,6 +1227,11 @@ i[data-tooltip]:hover:after { border-color: transparent transparent #EF6C00 transparent; } +.left-tooltip::after { + left: 0% !important; + margin-left: 281px !important; +} + /* Show the tooltip text when you mouse over the tooltip container */ .tooltip:hover .tooltiptext { visibility: visible; @@ -1274,7 +1290,10 @@ i[data-tooltip]:hover:after { } .label-group:last-child:last-of-type { margin-bottom: 16px;} -.border-bottom-not-last:not(:last-child) { border-bottom: 1px solid #C4C4C4; } +.border-bottom-not-last:not(:last-child) { + border-bottom: 1px solid #C4C4C4; + padding-bottom: 16px; +} .actions-card a { margin: 8px 16px; @@ -1389,6 +1408,74 @@ i[data-tooltip]:hover:after { .dropdown-check-list ul li:first-child { border-top: 0; } .dropdown-check-list.visible ul { display: block; } +/* Filter Dropdown */ +.dropdown-select { + position: relative; + display: block; + color: #007385; + height: 42px; +} +.dropdown-select .container { + position: relative; + cursor: pointer; + display: block; + padding: 8px 16px; + border: 1px solid #007385; + border-radius: 4px; + width: 100%; + font-size: 14px; + font-weight: bold; + height: inherit; +} + +.dropdown-select .container:active:after { + right: 20px; + top: auto; +} + +.dropdown-select .options { + display: none; + list-style: none; + padding-left: 0; + margin-top: 4px; + border-radius: 4px; + height: auto; + border-top: 1px solid #007385; + border-bottom: 1px solid #007385; + width: 100%; + position: absolute; + background-color: #fff; + z-index: 1; +} + +.dropdown-select .options div { + padding: 8px 16px; + border-radius: 0px; + border-right: 1px solid #007385; + border-left: 1px solid #007385; + font-size: 14px; + color: #000; + align-items: center; + display: flex; +} + +.dropdown-select .options div:hover { + background-color: #E8F1F3; +} + +.dropdown-select input[type=radio] { + width: 20px; + height: 20px; + accent-color: #007385; + margin-top: auto; +} + +.dropdown-select .options label { + margin-left: 12px; + width:100%; +} +.dropdown-select.visible .options { display: block; } + /* Language Searchbar */ .autocomplete { /*the container must be positioned relative:*/ @@ -1706,4 +1793,55 @@ i[data-tooltip]:hover:after { .spinner{ z-index:-1; display:none; +} + +.disable{ + opacity: 0.6; + cursor: not-allowed; +} + +.project-actions-alert { + margin: 8px auto; + width: 1250px; +} + +/* For Subscribed Icon */ +.subscribed-icon{ + color: #ef6c00; + margin: 14px 0 0 8px; +} +.subscribed-icon-wrapper{ + display: flex +} + +/* Subscription Counters */ +.subscription-counter { + background-color: #E8F1F3; + border-radius: 4px; + padding: 16px; +} + +.required-label { + position: relative; + cursor: pointer; +} + +.required-label[title]::after { + content: "Required"; + position: absolute; + bottom: 100%; + left: 89%; + transform: translateX(-50%); + padding: 5px 10px; + border-radius: 5px; + visibility: hidden; + transition: opacity 0.3s; + z-index: 1000; + pointer-events: none; +} + +.required-label[title]:hover::after { + visibility: visible; + background-color: #EF6C00; + color: #fff; } \ No newline at end of file diff --git a/localcontexts/static/css/register.css b/localcontexts/static/css/register.css index 3829a835b..326195991 100644 --- a/localcontexts/static/css/register.css +++ b/localcontexts/static/css/register.css @@ -63,7 +63,6 @@ border-radius: 0px 5px 5px 0px; font-size: 14px; align-items: center; - overflow-y: auto; /* Enable vertical scrolling */ max-height: 650px; overflow-x: hidden; /* Disable vertical scrolling */ } @@ -203,26 +202,29 @@ margin-right: 2%; } -input[type=checkbox] { +input[type=checkbox]:not(.toggle) { transform: scale(1.5); } /* Choose an account page */ .account-card { - width: 218px; - height: 390px; + width: 100%; display: flex; - flex-direction: column; - align-items: center; - padding: 15px; - box-shadow: 0px 2px 6px rgba(0, 0, 0, 0.25); - border-radius: 12px; + padding: 12px 16px; + border-radius: 4px; + border: 1px solid #007385; } -.account-card:nth-child(2) { - margin-left: 10px; - margin-right: 10px; +.account-card.card-selected { + background: #E8F1F3; + border-width: 2px; +} + +.account-card input[type=radio] { + width: 20px; + height: 20px; + accent-color: #007385; } .white-btn-select { @@ -250,9 +252,9 @@ input[type=checkbox] { height: 38.46px; } -.tiny-notice { - width: 42.59px; - height: 42.59px; +.tiny-notice, .tiny-sp-logo { + width: 50px; + height: 50px; } /* Little orange div on login page */ diff --git a/localcontexts/static/images/logos/sp-badge-orange.png b/localcontexts/static/images/logos/sp-badge-orange.png new file mode 100644 index 000000000..b055d7d52 Binary files /dev/null and b/localcontexts/static/images/logos/sp-badge-orange.png differ diff --git a/localcontexts/static/images/logos/sp-logo-black.png b/localcontexts/static/images/logos/sp-logo-black.png new file mode 100644 index 000000000..311720818 Binary files /dev/null and b/localcontexts/static/images/logos/sp-logo-black.png differ diff --git a/localcontexts/static/images/logos/sp-logo-grey.png b/localcontexts/static/images/logos/sp-logo-grey.png new file mode 100644 index 000000000..6b1e0806e Binary files /dev/null and b/localcontexts/static/images/logos/sp-logo-grey.png differ diff --git a/localcontexts/static/images/logos/sp-logo-white.png b/localcontexts/static/images/logos/sp-logo-white.png new file mode 100644 index 000000000..aa6d7433c Binary files /dev/null and b/localcontexts/static/images/logos/sp-logo-white.png differ diff --git a/localcontexts/static/images/placeholders/service-provider-place.jpg b/localcontexts/static/images/placeholders/service-provider-place.jpg new file mode 100644 index 000000000..3ddbdc912 Binary files /dev/null and b/localcontexts/static/images/placeholders/service-provider-place.jpg differ diff --git a/localcontexts/static/javascript/main.js b/localcontexts/static/javascript/main.js index c89b5e2a1..813ee56cd 100644 --- a/localcontexts/static/javascript/main.js +++ b/localcontexts/static/javascript/main.js @@ -13,6 +13,12 @@ if (passwordField) { passwordField.addEventListener('focusout', (event) => { helpTextDiv.style.display = 'none' }) } +// Email validation function +function isValidEmail(email) { + var emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + return emailRegex.test(email); +} + var registerUserBtn = document.getElementById('registerUserBtn') if (registerUserBtn) { registerUserBtn.addEventListener('click', () => disableSubmitRegistrationBtn()) } @@ -96,8 +102,8 @@ document.addEventListener('DOMContentLoaded', () => { }; const url = window.location.href; - const createPages = ['create-community', 'create-institution', 'connect-researcher']; - const updatePages = ['communities/update', 'institutions/update', 'researchers/update']; + const createPages = ['create-community', 'create-institution', 'connect-researcher', 'create-service-provider']; + const updatePages = ['communities/update', 'institutions/update', 'researchers/update', 'service-providers/update']; if (createPages.some(page => url.includes(page)) || updatePages.some(page => url.includes(page))) { initializeCharacterCounter('id_description', 'charCount', 200); @@ -1022,6 +1028,7 @@ if (projectTypeSelect) { }) } +let selectedDivCount = 0; // PROJECTS: NOTIFY communities - select desired communities function selectCommunities() { let select = document.getElementById('communities-select') @@ -1033,10 +1040,12 @@ function selectCommunities() { let selectedCommunityDiv = document.getElementById(`selected-community-${option.id}`) let div = document.getElementById(`comm-id-input-${option.id}`) - if (option.selected) { + if (option.selected && !selectedCommunityDiv.classList.contains('show')) { + selectedDivCount++; selectedCommunityDiv.classList.replace('hide', 'show') div.innerHTML = `` } + select.disabled = notification_count > 1 && selectedDivCount >= notification_count; }) } @@ -1045,12 +1054,19 @@ function cancelCommunitySelection(elem) { let id = elem.id let matches = id.match(/(\d+)/) let targetNum = matches[0] - let divToClose = document.getElementById(`selected-community-${targetNum}`) let inputDivToRemove = document.getElementById(`comm-id-input-${targetNum}`) + var select = document.getElementById('communities-select') divToClose.classList.replace('show', 'hide') inputDivToRemove.innerHTML = `` + if (selectedDivCount > 0) { + selectedDivCount--; + } + if (selectedDivCount < notification_count) { + select.disabled = false; + } + } @@ -1605,8 +1621,24 @@ if (window.location.href.includes('newsletter/preferences/') ) { } } +// Add API Key Modals +if (window.location.href.includes('api-key')) { + const generateAPIKeymodal = document.getElementById('generateAPIKeymodal') + const generateAPIKeybtn = document.getElementById('generateAPIKeybtn') + const deleteAPIKeymodal = document.getElementById('deleteAPIKeymodal') + + generateAPIKeybtn.addEventListener('click', () => { + if (generateAPIKeymodal.classList.contains('hide')) { generateAPIKeymodal.classList.replace('hide', 'show')} + }) + const closegenerateAPIKeymodal = document.getElementById('closegenerateAPIKeymodal') + closegenerateAPIKeymodal.addEventListener('click', function() { generateAPIKeymodal.classList.replace('show', 'hide')}) + + const closedeleteAPIKeymodal = document.getElementById('closedeleteAPIKeymodal') + closedeleteAPIKeymodal.addEventListener('click', function() { deleteAPIKeymodal.classList.replace('show', 'hide')}) +} + // REGISTRY FILTERING AND JOIN REQUESTS / CONTACT MODAL -if (window.location.href.includes('communities/view/') || window.location.href.includes('institutions/view/') || window.location.href.includes('researchers/view/') ) { +if (window.location.href.includes('communities/view/') || window.location.href.includes('institutions/view/') || window.location.href.includes('researchers/view/') || window.location.href.includes('service-providers/view/') ) { // Join request modal and form const openRequestToJoinModalBtn = document.getElementById('openRequestToJoinModalBtn') @@ -1660,6 +1692,19 @@ if (window.location.href.includes('communities/view/') || window.location.href.i } } +if ( + window.location.href.includes('/invitations/') +) { + document.addEventListener('DOMContentLoaded', function() { + var disabledDiv = document.querySelector('.disabled-btn'); + if (disabledDiv) { + disabledDiv.addEventListener('click', function(event) { + event.preventDefault(); + event.stopPropagation(); + }); + } + }); +} // ONBOARDING MODAL: Shows up in dashboard if there isn't a localstorage item saved and onboarding_on is set to true if (window.location.href.includes('dashboard')) { const hiddenInput = document.getElementById('openOnboarding') @@ -1732,10 +1777,12 @@ if (window.location.href.includes('notices')) { const closeAddURLModal = document.getElementById('closeAddURLModal') closeAddURLModal.addEventListener('click', function() { OTCModal.classList.replace('show', 'hide')}) - // Share OTC Notice Modal - shareBtn.addEventListener('click', () => { - if (shareOTCNoticeModal.classList.contains('hide')) { shareOTCNoticeModal.classList.replace('hide', 'show')} - }) + if (shareBtn){ + // Share OTC Notice Modal + shareBtn.addEventListener('click', () => { + if (shareOTCNoticeModal.classList.contains('hide')) { shareOTCNoticeModal.classList.replace('hide', 'show')} + }) + } const closeshareOTCNoticeModal = document.getElementById('closeshareOTCNoticeModal') closeshareOTCNoticeModal.addEventListener('click', function() { shareOTCNoticeModal.classList.replace('show', 'hide')}) @@ -1897,6 +1944,7 @@ function shareToSocialsBtnAction(btnElem) { } function openModal(modalId, closeBtnId) { + console.log(modalId) const modal = document.getElementById(modalId) modal.classList.replace('hide', 'show') @@ -1907,14 +1955,15 @@ function openModal(modalId, closeBtnId) { }) } -function toggleProjectInfo(self, idToToggle) { +function toggleSectionInfo(self, idToToggle) { let div = document.getElementById(idToToggle) - let allDivs = document.querySelectorAll('.project-header-div') + let allDivs = document.querySelectorAll('.section-header-div') let lastDiv = allDivs[allDivs.length - 1] if (div.style.height == "0px") { self.innerHTML = '' div.style.height = 'auto' + div.style.overflow = 'visible' self.parentElement.classList.add('border-bottom-solid-teal') if (self.parentElement != lastDiv) { @@ -1922,6 +1971,7 @@ function toggleProjectInfo(self, idToToggle) { } } else { div.style.height = '0px' + div.style.overflow = 'hidden' self.innerHTML = '' self.parentElement.classList.remove('border-bottom-solid-teal') @@ -1994,6 +2044,8 @@ if (window.location.href.includes('create-institution') && !window.location.href const createInstitutionBtn = document.getElementById('createInstitutionBtn') const clearFormBtn = document.getElementById('clearFormBtn') const descriptionField = document.getElementById('id_description') + const contactNameField = document.getElementById('institutionContactNameField') + const contactEmailField = document.getElementById('institutionContactEmailField') let characterCounter = document.getElementById('charCount') let delayTimer @@ -2033,6 +2085,8 @@ if (window.location.href.includes('create-institution') && !window.location.href stateProvRegionInputField.value = '' countryInputField.value = '' descriptionField.value = '' + contactNameField.value = '' + contactEmailField.value = '' characterCounter.textContent = '200/200' }) @@ -2078,8 +2132,8 @@ if (window.location.href.includes('create-institution') && !window.location.href function clearSuggestions() { suggestionsContainer.innerHTML = '' } } -if (window.location.href.includes('/institutions/update/') || window.location.href.includes('/communities/update/') || window.location.href.includes('/researchers/update/')) { - const realImageUploadBtn = document.getElementById('institutionImgUploadBtn') || document.getElementById('communityImgUploadBtn') || document.getElementById('researcherImgUploadBtn') +if (window.location.href.includes('/institutions/update/') || window.location.href.includes('/communities/update/') || window.location.href.includes('/researchers/update/') || window.location.href.includes('/service-providers/update/')) { + const realImageUploadBtn = document.getElementById('institutionImgUploadBtn') || document.getElementById('communityImgUploadBtn') || document.getElementById('researcherImgUploadBtn') || document.getElementById('serviceProviderImgUploadBtn') const customImageUploadBtn = document.getElementById('altImageUploadBtn') const imagePreviewContainer = document.getElementById('imagePreviewContainer') @@ -2114,29 +2168,6 @@ if (window.location.href.includes('/institutions/update/') || window.location.hr }) } - if (window.location.href.includes('/confirm-community/')) { - const realFileUploadBtn = document.getElementById('communitySupportLetterUploadBtn') - const customFileUploadBtn = document.getElementById('customFileUploadBtn') - const form = document.querySelector('#confirmationForm') - const contactEmailInput = document.getElementById('communityContactEmailField') - - function showFileName() { - const selectedFile = realFileUploadBtn.files[0] - customFileUploadBtn.innerHTML = `${selectedFile.name} ` - } - - customFileUploadBtn.addEventListener('click', function(e) { - e.preventDefault() - realFileUploadBtn.click() - }) - - form.addEventListener('submit', function(e) { - if (realFileUploadBtn.files.length === 0 && contactEmailInput.value.trim() === '') { - e.preventDefault() - alert('Please either enter a contact email or upload a support file') - } - }) - } if (window.location.href.includes('institutions/notices/')) { const realFileUploadBtn = document.getElementById('ccNoticePolicyUploadBtn') @@ -2181,18 +2212,20 @@ if (window.location.href.includes('/institutions/update/') || window.location.hr // Collections Care Button Download const ccNoticeDownloadBtn = document.getElementById('ccNoticeDownloadBtn') - ccNoticeDownloadBtn.addEventListener('click', function() { - let oldValue = 'Download Notices ' - ccNoticeDownloadBtn.setAttribute('disabled', true) - ccNoticeDownloadBtn.innerHTML = 'Downloading
' - - // Re-enable the button after a certain timeout - // re-enable it after a while, assuming an average download duration - setTimeout(function() { - ccNoticeDownloadBtn.innerHTML = oldValue - ccNoticeDownloadBtn.removeAttribute('disabled') - }, 15000) - }) + if (ccNoticeDownloadBtn){ + ccNoticeDownloadBtn.addEventListener('click', function() { + let oldValue = 'Download Notices ' + ccNoticeDownloadBtn.setAttribute('disabled', true) + ccNoticeDownloadBtn.innerHTML = 'Downloading
' + + // Re-enable the button after a certain timeout + // re-enable it after a while, assuming an average download duration + setTimeout(function() { + ccNoticeDownloadBtn.innerHTML = oldValue + ccNoticeDownloadBtn.removeAttribute('disabled') + }, 15000) + }) + } } if (window.location.href.includes('/communities/labels/customize/') || window.location.href.includes('/communities/labels/edit/')) { @@ -2234,7 +2267,7 @@ if (window.location.href.includes('/institutions/update/') || window.location.hr } - if (window.location.href.includes('communities/members/') || window.location.href.includes('institutions/members/')) { + if (window.location.href.includes('communities/members/') || window.location.href.includes('institutions/members/') || window.location.href.includes('service-providers/members/')) { // Add member modal function openAddModalView() { @@ -2273,3 +2306,167 @@ if (window.location.href.includes('/institutions/update/') || window.location.hr } } } + +if (window.location.href.includes('subscription-inquiry')) { + function cancelDisclaimer() { + var joinAlert = document.getElementById('disclaimerAlert'); + joinAlert.style.display = 'none'; + } + document.addEventListener('DOMContentLoaded', function() { + const nameInputField = document.getElementById('organizationInput') + const suggestionsContainer = document.getElementById('suggestionsContainer') + + let delayTimer + + nameInputField.addEventListener('input', () => { + clearTimeout(delayTimer) + + const inputValue = nameInputField.value.trim() + if (inputValue.length >= 3) { // Minimum characters required before making a request + let queryURL = 'https://api.ror.org/organizations?query=' + + delayTimer = setTimeout(() => { + + var matchingInstitutions = nonRorInstitutes.filter(function(item) { + return item.fields.institution_name.toLowerCase().includes(inputValue.toLowerCase()); + }); + var matchingCommunities = communities.filter(function(item){ + return item.fields.community_name.toLowerCase().includes(inputValue.toLowerCase()); + }); + var matchingServiceProviders = serviceProviders.filter(function(item){ + return item.fields.name.toLowerCase().includes(inputValue.toLowerCase()); + }); + + fetch(`${queryURL}${encodeURIComponent(inputValue)}`) + .then(response => response.json()) + .then(data => { + showSuggestions(data.items, matchingInstitutions, matchingCommunities, matchingServiceProviders, inputValue) + + }) + .catch(error => { console.error(error)}) + + }, 300) // Delay in milliseconds before making the request + } else { clearSuggestions() } + }) + + function showSuggestions(items, matchingInstitutions, matchingCommunities, matchingServiceProviders, userInput) { + // Clear previous suggestions + clearSuggestions() + // Get the first 5 most relevant itemss + const combinedItems = [...matchingInstitutions, ...matchingCommunities, ...matchingServiceProviders, ...items]; + + const filteredItems = combinedItems.filter(item => + (typeof item === 'object' && item.name?.toLowerCase().includes(userInput.toLowerCase())) || + (typeof item === 'object' && item.fields?.institution_name?.toLowerCase().includes(userInput.toLowerCase())) || + (typeof item === 'object' && item.fields?.community_name?.toLowerCase().includes(userInput.toLowerCase())) || + (typeof item === 'object' && item.fields?.name?.toLowerCase().includes(userInput.toLowerCase())) + ); + + // Check if any item exactly matches the user input + const exactMatch = combinedItems.some(item => + (typeof item === 'object' && item.name?.toLowerCase() === userInput.toLowerCase()) || + (typeof item === 'object' && item.fields?.institution_name?.toLowerCase() === userInput.toLowerCase()) || + (typeof item === 'object' && item.fields?.community_name?.toLowerCase() === userInput.toLowerCase()) || + (typeof item === 'object' && item.fields?.name?.toLowerCase() === userInput.toLowerCase()) + ); + const relevantItems = filteredItems.slice(0, 5); + // If no exact match, show 'not found in ROR List' message + if (!exactMatch) { + const suggestionItem = document.createElement('div'); + suggestionItem.classList.add('suggestion-item'); + suggestionItem.innerHTML = `${userInput} (Not in List)`; + suggestionItem.addEventListener('click', () => { + nameInputField.value = userInput; + clearSuggestions(); + }); + suggestionsContainer.appendChild(suggestionItem); + } + displaySuggestions(relevantItems); + + } + + function displaySuggestions(items) { + items.forEach(item => { + const suggestionItem = document.createElement('div'); + suggestionItem.classList.add('suggestion-item'); + + let displayName = ''; + let displayDetails = ''; + + if (typeof item === 'object' && item.hasOwnProperty('name')) { + displayName = item.name; + displayDetails = `${item.types.join(", ")}, ${item.country.country_name} Institution`; + } else if (typeof item === 'object' && item.hasOwnProperty('fields') && item.model === "institutions.institution") { + displayName = item.fields.institution_name; + displayDetails = `${item.fields.country ? item.fields.country + " " : ""}Institution`; + } else if (typeof item === 'object' && item.hasOwnProperty('fields') && item.model === "communities.community") { + displayName = item.fields.community_name; + displayDetails = `${item.fields.country ? item.fields.country + " " : ""}Community`; + } else if (typeof item === 'object' && item.hasOwnProperty('fields') && item.model === "serviceproviders.serviceprovider") { + displayName = item.fields.name; + displayDetails = `Service Provider`; + } + suggestionItem.innerHTML = `${displayName}
${displayDetails}`; + + suggestionItem.addEventListener('click', () => { + nameInputField.value = displayName; + clearSuggestions(); + }); + + suggestionsContainer.appendChild(suggestionItem); + }); + } + + function clearSuggestions() { suggestionsContainer.innerHTML = '' } + })} + +if (window.location.href.includes('subscription-form')) { + document.addEventListener("DOMContentLoaded", function () { + const firstNameInput = document.querySelector('input[name="first_name"]'); + const lastNameInput = document.querySelector('input[name="last_name"]'); + const emailInput = document.querySelector('input[type="email"]'); + const organizationInput = document.querySelector('input[name="organization_name"]'); + const inquiryTypeRadios = document.querySelectorAll('input[name="inquiry_type"]'); + const submitButton = document.getElementById("createSubscription"); + const clearFormBtn = document.getElementById('clearFormBtn') + + validateForm() + submitButton.addEventListener("click", disableButton) + firstNameInput.addEventListener("input", validateForm); + emailInput.addEventListener("input", validateForm); + if (inquiryTypeRadios.length > 0) { + inquiryTypeRadios.forEach(radio => radio.addEventListener("change", validateForm)); + } + + function validateForm() { + const firstNameFilled = firstNameInput.value.trim() !== ""; + const emailFilled = emailInput.value.trim() !== "" && isValidEmail(emailInput.value.trim()); + const inquiryTypeSelected = inquiryTypeRadios.length > 0 + ? Array.from(inquiryTypeRadios).some(radio => radio.checked) + : true; + // Enable the button if all fields are valid + if (firstNameFilled && emailFilled && inquiryTypeSelected) { + submitButton.disabled = false; + } else { + submitButton.disabled = true; + } + } + + function disableButton() { + document.getElementById("createSubscription").style.display = "none"; + document.getElementById("loading-spinner").classList.remove('hide'); + } + + clearFormBtn.addEventListener('click', (e) => { + e.preventDefault() + firstNameInput.value = '' + submitButton.disabled = true + + lastNameInput.value = '' + emailInput.value = '' + inquiryTypeRadios.forEach(radio => { + radio.checked = false; + }); + }) +}); +}; \ No newline at end of file diff --git a/localcontexts/static/json/NoticeTranslations.json b/localcontexts/static/json/NoticeTranslations.json index 7368ef98e..54d526869 100644 --- a/localcontexts/static/json/NoticeTranslations.json +++ b/localcontexts/static/json/NoticeTranslations.json @@ -70,5 +70,29 @@ "noticeDefaultText": "Ko tā te Pānui Whakamārama TK, he āta whakaatu, tērā ētahi tikanga ā-iwi me ōna haepapa ki runga i te whakamahinga, i te horapatanga hoki o tēnei taonga. Kei roto hoki pea i tēnei Pānui TK, ko te kōrero e mea ana, tērā ngā Tohu TK e waihangatia ana, ā, kei te whiriwhirihia tonutia tōna whakatinanatanga. Mō ētahi atu kōrero mō ngā Pānui Whakamārama TK, pāwhiritia i konei.", "languageTag": "mi", "language": "Māori" + }, + { + "id": 10, + "noticeName": "Abierto a Colaboración", + "noticeType": "open_to_collaborate", + "noticeDefaultText": "Las colecciones y documentos en nuestra institución tienen atribuciones incompletas, imprecisas y/o faltantes. Utilizamos esta Notificación para identificar claramente este material y que pueda ser actualizado o corregido por las comunidades de origen. Nuestra institución está comprometida a llevar a cabo colaboraciones y alianzas para resolver este problema de atribuciones incorrectas o faltantes.", + "languageTag": "es", + "language": "Spanish" + }, + { + "id": 11, + "noticeName": "Ouverts à la collaboration", + "noticeType": "open_to_collaborate", + "noticeDefaultText": "Notre institution s’engage à développer de nouveaux modes de collaboration, d’engagement et de partenariat avec les peuples autochtones pour la conservation et la gestion des collections patrimoniales passées et futures.", + "languageTag": "fr", + "language": "French" + }, + { + "id": 12, + "noticeName": "Kaupapa Whakangātahi", + "noticeType": "open_to_collaborate", + "noticeDefaultText": "E whakaū ana tō mātou wānanga i a ia anō, ki te pono me te tōtika kia whakamātauria ngā ara hou, me ngā ara pai me te āta tautiaki i ngā kohinga iwi taketake o mua, ā, ki tua o anamata.", + "languageTag": "mi", + "language": "Māori" } ] \ No newline at end of file diff --git a/localcontexts/static/json/Notices.json b/localcontexts/static/json/Notices.json index 108273eb2..e2ffd97d4 100644 --- a/localcontexts/static/json/Notices.json +++ b/localcontexts/static/json/Notices.json @@ -22,5 +22,13 @@ "noticeDefaultText": "The TK (Traditional Knowledge) Notice is a visible notification that there are accompanying cultural rights and responsibilities that need further attention for any future sharing and use of this material. The TK Notice may indicate that TK Labels are in development and their implementation is being negotiated.", "imgFileName": "tk-notice.png", "svgFileName": "tk-notice.svg" + }, + { + "id": 4, + "noticeName": "Open to Collaborate Notice", + "noticeType": "open_to_collaborate", + "noticeDefaultText": "Our institution is committed to the development of new modes of collaboration, engagement, and partnership with Indigenous peoples for the care and stewardship of past and future heritage collections.", + "imgFileName": "ci-open-to-collaborate.png", + "svgFileName": "ci-open-to-collaborate.svg" } ] diff --git a/localcontexts/urls.py b/localcontexts/urls.py index d0c86d0e6..272f43714 100644 --- a/localcontexts/urls.py +++ b/localcontexts/urls.py @@ -31,6 +31,7 @@ path('communities/', include('communities.urls')), path('institutions/', include('institutions.urls')), path('researchers/', include('researchers.urls')), + path('service-providers/', include('serviceproviders.urls')), path('projects/', include('projects.urls')), path('helpers/', include('helpers.urls')), path('api/', include('api.urls')), diff --git a/localcontexts/wsgi.py b/localcontexts/wsgi.py index 234655eee..7d9db4fe6 100644 --- a/localcontexts/wsgi.py +++ b/localcontexts/wsgi.py @@ -12,6 +12,7 @@ from django.core.wsgi import get_wsgi_application os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'localcontexts.settings') +# Import your start_scheduler function application = get_wsgi_application() @@ -19,4 +20,5 @@ from helpers.scheduler import start_scheduler # Call start_scheduler when the WSGI application is loaded +from helpers.scheduler import start_scheduler start_scheduler() diff --git a/notifications/migrations/0041_usernotification_service_provider.py b/notifications/migrations/0041_usernotification_service_provider.py new file mode 100644 index 000000000..c039b8adb --- /dev/null +++ b/notifications/migrations/0041_usernotification_service_provider.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.6 on 2024-07-09 20:36 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0040_auto_20231220_1706'), + ('serviceproviders', '0002_serviceprovider_documentation_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='usernotification', + name='service_provider', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='serviceproviders.serviceprovider'), + ), + ] diff --git a/notifications/migrations/0042_actionnotification_service_provider.py b/notifications/migrations/0042_actionnotification_service_provider.py new file mode 100644 index 000000000..1705fe842 --- /dev/null +++ b/notifications/migrations/0042_actionnotification_service_provider.py @@ -0,0 +1,20 @@ +# Generated by Django 5.0.6 on 2024-07-17 21:25 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0041_usernotification_service_provider'), + ('serviceproviders', '0002_serviceprovider_documentation_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='actionnotification', + name='service_provider', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='serviceproviders.serviceprovider'), + ), + ] diff --git a/notifications/models.py b/notifications/models.py index 17da96aa9..ea4f025a3 100644 --- a/notifications/models.py +++ b/notifications/models.py @@ -4,6 +4,7 @@ from communities.models import Community from institutions.models import Institution from researchers.models import Researcher +from serviceproviders.models import ServiceProvider class UserNotification(models.Model): @@ -33,6 +34,7 @@ class UserNotification(models.Model): notification_type = models.CharField(max_length=10, choices=TYPES, null=True, blank=True) community = models.ForeignKey(Community, on_delete=models.CASCADE, null=True, blank=True) institution = models.ForeignKey(Institution, on_delete=models.CASCADE, null=True, blank=True) + service_provider = models.ForeignKey(ServiceProvider, on_delete=models.CASCADE, null=True, blank=True) role = models.CharField(max_length=8, choices=ROLES, null=True, blank=True) reference_id = models.CharField(max_length=20, null=True, blank=True) viewed = models.BooleanField(default=False, blank=True) @@ -68,6 +70,7 @@ class ActionNotification(models.Model): community = models.ForeignKey(Community, on_delete=models.CASCADE, null=True, blank=True) institution = models.ForeignKey(Institution, on_delete=models.CASCADE, null=True, blank=True) researcher = models.ForeignKey(Researcher, on_delete=models.CASCADE, null=True, blank=True) + service_provider = models.ForeignKey(ServiceProvider, on_delete=models.CASCADE, null=True, blank=True) reference_id = models.CharField(max_length=50, null=True, blank=True) viewed = models.BooleanField(default=False, blank=True) created = models.DateTimeField(auto_now_add=True, null=True, blank=True) diff --git a/notifications/templatetags/custom_notification_tags.py b/notifications/templatetags/custom_notification_tags.py index 0fa87f8db..b03340954 100644 --- a/notifications/templatetags/custom_notification_tags.py +++ b/notifications/templatetags/custom_notification_tags.py @@ -5,6 +5,7 @@ from institutions.models import Institution from notifications.models import ActionNotification, UserNotification from researchers.models import Researcher +from serviceproviders.models import ServiceProvider register = template.Library() @@ -24,6 +25,9 @@ def unread_notifications_exist(account): if isinstance(account, Community): return ActionNotification.objects.filter(community=account, viewed=False).exists() + if isinstance(account, ServiceProvider): + return ActionNotification.objects.filter(service_provider=account, viewed=False).exists() + return False @@ -42,4 +46,7 @@ def return_notifications(account): if isinstance(account, Community): return ActionNotification.objects.filter(community=account) + if isinstance(account, ServiceProvider): + return ActionNotification.objects.filter(service_provider=account) + return None diff --git a/notifications/utils.py b/notifications/utils.py index a571c3dbd..db7a52f39 100644 --- a/notifications/utils.py +++ b/notifications/utils.py @@ -4,16 +4,20 @@ from helpers.models import HubActivity from institutions.models import Institution from researchers.models import Researcher +from serviceproviders.models import ServiceProvider +from helpers.models import HubActivity +from bclabels.models import BCLabel from tklabels.models import TKLabel from .models import ActionNotification, UserNotification - +# ACTION NOTIFICATIONS def send_simple_action_notification(sender, target_org, title, notification_type, reference_id): target_type_mapping = { Community: 'community', Institution: 'institution', Researcher: 'researcher', + ServiceProvider: 'service_provider', } target_type_key = target_type_mapping.get(type(target_org)) @@ -46,12 +50,18 @@ def send_action_notification_to_project_contribs( ) +def delete_action_notification(reference_id): + notifications = ActionNotification.objects.filter(reference_id=reference_id) + for notification in notifications: + notification.delete() + + # MEMBER INVITES -def send_account_member_invite(invite): # Send notification when community - # or institution sends a member invite to a user +def send_account_member_invite(invite): # Send notification when community, institution + # or service provider sends a member invite to a user sender_name = get_users_name(invite.sender) - entity = invite.community or invite.institution - entity_type = 'community' if invite.community else 'institution' + entity = invite.community or invite.institution or invite.service_provider + entity_type = 'community' if invite.community else 'institution' if invite.institution else 'service_provider' title = f"{sender_name} has invited you to join {entity}." message = invite.message or f"You've been invited to join " \ @@ -71,13 +81,13 @@ def send_account_member_invite(invite): # Send notification when community def send_user_notification_member_invite_accept( member_invite -): # Send notification when user accepts - # a member invite from community or institution +): # Send notification when user accepts a member invite + # from community, institution or service provider sender_ = member_invite.sender receiver_ = member_invite.receiver receiver_name = get_users_name(receiver_) - entity = member_invite.community or member_invite.institution - entity_type = 'community' if member_invite.community else 'institution' + entity = member_invite.community or member_invite.institution or member_invite.service_provider + entity_type = 'community' if member_invite.community else 'institution' if member_invite.institution else 'service_provider' # Lets user know they are now a member title = f"You are now a member of {entity}." diff --git a/projects/migrations/0191_alter_project_project_privacy.py b/projects/migrations/0191_alter_project_project_privacy.py new file mode 100644 index 000000000..a6eb9ca52 --- /dev/null +++ b/projects/migrations/0191_alter_project_project_privacy.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2024-04-05 18:44 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0190_merge_20240126_2126'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='project_privacy', + field=models.CharField(choices=[('Public', 'Public'), ('Contributor', 'Contributor'), ('Private', 'Private')], max_length=20, null=True), + ), + ] diff --git a/projects/migrations/0191_alter_project_project_privacy_and_more.py b/projects/migrations/0191_alter_project_project_privacy_and_more.py index 7ef3f0c0f..b437edab9 100644 --- a/projects/migrations/0191_alter_project_project_privacy_and_more.py +++ b/projects/migrations/0191_alter_project_project_privacy_and_more.py @@ -1,4 +1,4 @@ -# Generated by Django 4.2.11 on 2024-06-11 19:14 +# Generated by Django 4.2 on 2024-03-29 12:12 from django.db import migrations, models @@ -6,18 +6,32 @@ class Migration(migrations.Migration): dependencies = [ - ('projects', '0190_merge_20240126_2126'), + ("projects", "0190_merge_20240126_2126"), ] operations = [ migrations.AlterField( - model_name='project', - name='project_privacy', - field=models.CharField(choices=[('Public', 'Public'), ('Contributor', 'Contributor'), ('Private', 'Private')], max_length=20, null=True), + model_name="project", + name="project_privacy", + field=models.CharField( + choices=[ + ("Public", "Public"), + ("Contributor", "Contributor"), + ("Private", "Private"), + ], + max_length=20, + null=True, + ), ), migrations.AlterField( - model_name='project', - name='related_projects', - field=models.ManyToManyField(blank=True, db_index=True, related_name='related_projects', to='projects.project', verbose_name='Related Projects'), + model_name="project", + name="related_projects", + field=models.ManyToManyField( + blank=True, + db_index=True, + related_name="related_projects", + to="projects.project", + verbose_name="Related Projects", + ), ), ] diff --git a/projects/migrations/0192_alter_project_related_projects.py b/projects/migrations/0192_alter_project_related_projects.py new file mode 100644 index 000000000..f1bb7e5b4 --- /dev/null +++ b/projects/migrations/0192_alter_project_related_projects.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2024-04-11 18:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0191_alter_project_project_privacy_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='related_projects', + field=models.ManyToManyField(blank=True, db_index=True, related_name='_projects_project_related_projects_+', to='projects.Project', verbose_name='Related Projects'), + ), + ] diff --git a/projects/migrations/0192_merge_20240408_1250.py b/projects/migrations/0192_merge_20240408_1250.py new file mode 100644 index 000000000..d18fb2b87 --- /dev/null +++ b/projects/migrations/0192_merge_20240408_1250.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2 on 2024-04-08 16:50 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0191_alter_project_project_privacy'), + ('projects', '0191_alter_project_project_privacy_and_more'), + ] + + operations = [ + ] diff --git a/projects/migrations/0193_alter_project_related_projects.py b/projects/migrations/0193_alter_project_related_projects.py new file mode 100644 index 000000000..49916c963 --- /dev/null +++ b/projects/migrations/0193_alter_project_related_projects.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2 on 2024-04-08 16:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0192_merge_20240408_1250'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='related_projects', + field=models.ManyToManyField(blank=True, db_index=True, related_name='_projects_project_related_projects_+', to='projects.Project', verbose_name='Related Projects'), + ), + ] diff --git a/projects/migrations/0194_merge_20240412_1354.py b/projects/migrations/0194_merge_20240412_1354.py new file mode 100644 index 000000000..f5a887448 --- /dev/null +++ b/projects/migrations/0194_merge_20240412_1354.py @@ -0,0 +1,14 @@ +# Generated by Django 3.2 on 2024-04-12 17:54 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0192_alter_project_related_projects'), + ('projects', '0193_alter_project_related_projects'), + ] + + operations = [ + ] diff --git a/projects/migrations/0195_alter_project_related_projects.py b/projects/migrations/0195_alter_project_related_projects.py new file mode 100644 index 000000000..1145dafb1 --- /dev/null +++ b/projects/migrations/0195_alter_project_related_projects.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-05-09 20:36 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0194_merge_20240412_1354'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='related_projects', + field=models.ManyToManyField(blank=True, db_index=True, related_name='related_projects', to='projects.project', verbose_name='Related Projects'), + ), + ] diff --git a/projects/migrations/0196_alter_project_related_projects.py b/projects/migrations/0196_alter_project_related_projects.py new file mode 100644 index 000000000..0cd5b1e71 --- /dev/null +++ b/projects/migrations/0196_alter_project_related_projects.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-05-22 20:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0195_alter_project_related_projects'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='related_projects', + field=models.ManyToManyField(blank=True, db_index=True, to='projects.project', verbose_name='Related Projects'), + ), + ] diff --git a/projects/migrations/0197_merge_20240729_1516.py b/projects/migrations/0197_merge_20240729_1516.py new file mode 100644 index 000000000..2e8c0fc50 --- /dev/null +++ b/projects/migrations/0197_merge_20240729_1516.py @@ -0,0 +1,14 @@ +# Generated by Django 5.0.6 on 2024-07-29 19:16 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0192_project_boundary_project_name_of_boundary_and_more'), + ('projects', '0196_alter_project_related_projects'), + ] + + operations = [ + ] diff --git a/projects/migrations/0198_merge_20240913_1051.py b/projects/migrations/0198_merge_20240913_1051.py new file mode 100644 index 000000000..888b10911 --- /dev/null +++ b/projects/migrations/0198_merge_20240913_1051.py @@ -0,0 +1,14 @@ +# Generated by Django 5.0.6 on 2024-09-13 14:51 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0193_merge_20240826_1901'), + ('projects', '0197_merge_20240729_1516'), + ] + + operations = [ + ] diff --git a/projects/migrations/0199_alter_project_project_privacy.py b/projects/migrations/0199_alter_project_project_privacy.py new file mode 100644 index 000000000..06791c8f3 --- /dev/null +++ b/projects/migrations/0199_alter_project_project_privacy.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-09-17 14:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('projects', '0198_merge_20240913_1051'), + ] + + operations = [ + migrations.AlterField( + model_name='project', + name='project_privacy', + field=models.CharField(choices=[('Public', 'Public'), ('Contributor', 'Contributor'), ('Private', 'Private')], max_length=20, null=True), + ), + ] diff --git a/projects/models.py b/projects/models.py index 2e9aa919f..3c7e55105 100644 --- a/projects/models.py +++ b/projects/models.py @@ -19,7 +19,6 @@ class ProjectArchived(models.Model): class Meta: verbose_name_plural = 'Project Archived' - class Project(models.Model): TYPES = ( ('Item', 'Item'), @@ -53,13 +52,13 @@ class Project(models.Model): date_added = models.DateTimeField(auto_now_add=True, null=True) date_modified = models.DateTimeField(auto_now=True, null=True) source_project_uuid = models.UUIDField(null=True, verbose_name="Source Project UUID", blank=True, db_index=True) - related_projects = models.ManyToManyField("self", blank=True, verbose_name="Related Projects", related_name="related_projects", db_index=True) + related_projects = models.ManyToManyField("self", blank=True, verbose_name="Related Projects", db_index=True) bc_labels = models.ManyToManyField("bclabels.BCLabel", verbose_name="BC Labels", blank=True, related_name="project_bclabels", db_index=True) tk_labels = models.ManyToManyField("tklabels.TKLabel", verbose_name="TK Labels", blank=True, related_name="project_tklabels", db_index=True) source_of_boundary = models.CharField(max_length=400, blank=True, null=True) name_of_boundary = models.CharField(max_length=200, blank=True, null=True) - boundary = models.ForeignKey(Boundary, on_delete=models.CASCADE, null=True) + boundary = models.ForeignKey(Boundary, on_delete=models.CASCADE, blank=True, null=True) def has_labels(self): if self.bc_labels.exists() or self.tk_labels.exists(): @@ -97,13 +96,11 @@ def is_sub_project(self): def can_user_access(self, user): # returns either True, False, or 'partial' - if user == self.project_creator: - return True - elif self.project_privacy == 'Public': + if user == self.project_creator or self.project_privacy == 'Public' or self.project_privacy == 'Private': return True elif self.project_privacy == 'Contributor': return discoverable_project_view(self, user) - elif self.project_privacy == 'Private': + else: return False def get_template_name(self, user): @@ -114,7 +111,7 @@ def get_template_name(self, user): return 'partials/_project-actions.html' else: return 'partials/_project-contributor-view.html' - elif self.project_privacy == 'Private' and user == self.project_creator: + elif self.project_privacy == 'Private': return 'partials/_project-actions.html' else: return None @@ -194,11 +191,10 @@ def account_is_confirmed(self): when an account is a parent project or a researcher, it is considered already approved """ - account = self.community or self.institution - if account: - return account.is_approved - - return True + if self.community: + return self.community.is_approved + else: + return True def validate_user_access(self, user): is_created_by = { diff --git a/projects/signals.py b/projects/signals.py index 0afa98548..078ae04ca 100644 --- a/projects/signals.py +++ b/projects/signals.py @@ -18,7 +18,7 @@ def create_project_dependencies(sender, instance, created, **kwargs): ProjectCreator.objects.create(project=instance) @receiver(post_delete, sender=Project) -def delete_action_notifications(sender, instance, *args, **kwargs): +def delete_project_action_notifications(sender, instance, *args, **kwargs): if ActionNotification.objects.filter(reference_id=instance.unique_id).exists(): for notification in ActionNotification.objects.filter(reference_id=instance.unique_id): notification.delete() diff --git a/projects/templatetags/custom_project_tags.py b/projects/templatetags/custom_project_tags.py index cb960d377..1082497ed 100644 --- a/projects/templatetags/custom_project_tags.py +++ b/projects/templatetags/custom_project_tags.py @@ -17,16 +17,18 @@ def source_project_title(uuid): @register.simple_tag def get_all_researchers(researcher_to_exclude): if researcher_to_exclude: - return Researcher.objects.select_related('user').exclude(id=researcher_to_exclude.id) + return Researcher.objects.select_related('user')\ + .exclude(id=researcher_to_exclude.id)\ + .exclude(is_subscribed=False) else: - return Researcher.objects.select_related('user').all() + return Researcher.objects.select_related('user').all().exclude(is_subscribed=False) @register.simple_tag def get_all_institutions(institution_to_exclude): if institution_to_exclude: - return Institution.approved.exclude(id=institution_to_exclude.id) + return Institution.subscribed.exclude(id=institution_to_exclude.id) else: - return Institution.approved.all() + return Institution.subscribed.all() @register.simple_tag def get_all_communities(community_to_exclude): diff --git a/projects/utils.py b/projects/utils.py index 85e1fea20..11fc07d7d 100644 --- a/projects/utils.py +++ b/projects/utils.py @@ -198,7 +198,4 @@ def can_download_project(request, project_creator_instance): elif project_creator_instance.community: if not project_creator_instance.community.is_approved: can_download = False - elif project_creator_instance.institution: - if not project_creator_instance.institution.is_approved: - can_download = False return can_download diff --git a/projects/views.py b/projects/views.py index 3e66bdc51..ad6f06e6e 100644 --- a/projects/views.py +++ b/projects/views.py @@ -28,14 +28,13 @@ def view_project(request, unique_id): return render(request, '404.html', status=404) sub_projects = Project.objects.filter(source_project_uuid=project.unique_id).values_list('unique_id', 'title') - notices = Notice.objects.filter(project=project, archived=False) + notices = Notice.objects.filter(project=project, archived=False).exclude(notice_type='open_to_collaborate') communities = None institutions = None user_researcher = Researcher.objects.none() label_groups = return_project_labels_by_community(project) can_download = can_download_project(request, creator) - # If user is logged in AND belongs to account of a contributor if request.user.is_authenticated: affiliations = UserAffiliation.objects.get(user=request.user) diff --git a/requirements.txt b/requirements.txt index 1b71d8570..420fb7777 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,94 +1,157 @@ -appdirs==1.4.4 -APScheduler==3.10.4 -arabic-reshaper==3.0.0 -asgiref==3.7.2 -astroid==3.0.1 -boto3==1.26.77 -botocore==1.29.77 -cachetools==4.2.4 -certifi==2024.07.04 -charset-normalizer==2.0.6 -coverage==7.4.2 -cryptography>=43.0.1 -distlib==0.3.0 -dj-database-url==0.5.0 -Django>=4.2.10 -django-allauth==0.59.0 -django-appconf==1.0.4 -django-apscheduler==0.6.2 -django-chartjs==2.3.0 -django-classy-tags==2.0.0 -django-cleanup==5.1.0 -django-cookie-consent==0.2.6 -django-cors-headers==3.11.0 -django-countries==7.5.1 -django-dbbackup==4.0.2 -django-debug-toolbar==3.2.4 -django-guardian==2.3.0 -django-maintenance-mode==0.16.0 -django-storages==1.11.1 -django-widget-tweaks==1.4.8 -djangorestframework==3.15.2 -djangorestframework-api-key==2.2.0 -exceptiongroup==1.2.0 -factory-boy==3.3.0 -Faker==23.1.0 -filelock==3.0.12 -flake8==7.0.0 -future==0.18.3 -google-api-core==1.32.0 -google-auth==1.30.0 -google-cloud-core==1.7.2 -google-cloud-storage==1.38.0 -google-crc32c==1.3.0 -google-resumable-media==1.3.3 -googleapis-common-protos==1.53.0 -gunicorn==22.0.0 -html5lib==1.1 -idna==3.7 -isort==5.4.2 -jmespath==0.10.0 -lazy-object-proxy==1.4.3 -mccabe==0.7.0 -oauthlib==3.2.2 -oscrypto==1.3.0 -packaging==21.0 -pillow>=10.3.0 -pluggy==1.4.0 -protobuf==3.18.3 -psycopg2-binary==2.9.9 -pyasn1==0.4.8 -pyasn1-modules==0.2.8 -pyleniumio==1.21.0 -pylint==3.0.3 -pylint-django==2.3.0 -pylint-plugin-utils==0.6 -pyparsing==2.4.7 -pypdf==4.0.2 -PyPDF2==1.27.9 -pytest==8.0.0 -pytest-cov==4.1.0 -pytest-django==4.7.0 -pytest-html==4.1.1 -pytest-metadata==3.0.0 -pytest-runner==5.3.1 -python-bidi==0.4.2 -python-dateutil==2.8.1 -pytz==2020.1 -PyYAML==6.0.1 -requests>=2.32.0 -requests-oauthlib==1.3.1 -rsa==4.7.2 -s3transfer==0.6.0 -six==1.15.0 -sqlparse==0.5.0 -toml==0.10.1 -Unidecode==1.3.6 -uritools==4.0.2 -urllib3>=1.26.19 -virtualenv==20.0.23 -webencodings==0.5.1 -whitenoise==5.2.0 -wrapt==1.12.1 -xhtml2pdf==0.2.11 -yapf==0.40.2 +allure-pytest==2.13.5 +allure-python-commons==2.13.5 +annotated-types==0.7.0 +appdirs==1.4.4 +APScheduler==3.10.4 +arabic-reshaper==3.0.0 +asgiref==3.7.2 +asn1crypto==1.5.1 +astroid==3.0.1 +attrs==24.2.0 +axe-selenium-python==2.1.6 +boto3==1.26.77 +botocore==1.29.77 +cachetools==4.2.4 +certifi==2024.07.04 +cffi==1.17.1 +charset-normalizer==2.0.6 +click==8.1.7 +colorama==0.4.6 +coverage==7.4.2 +cryptography>=43.0.1 +cssselect2==0.7.0 +defusedxml==0.7.1 +dill==0.3.9 +distlib==0.3.0 +dj-database-url==0.5.0 +Django>=4.2.10 +django-allauth==0.59.0 +django-appconf==1.0.4 +django-apscheduler==0.6.2 +django-chartjs==2.3.0 +django-classy-tags==2.0.0 +django-cleanup==5.1.0 +django-cookie-consent==0.2.6 +django-cors-headers==3.11.0 +django-countries==7.5.1 +django-dbbackup==4.0.2 +django-debug-toolbar==3.2.4 +django-filter==24.2 +django-guardian==2.3.0 +django-maintenance-mode==0.16.0 +django-storages==1.11.1 +django-widget-tweaks==1.4.8 +djangorestframework==3.15.2 +djangorestframework-api-key==2.2.0 +drf-spectacular==0.27.2 +exceptiongroup==1.2.0 +execnet==2.1.1 +factory-boy==3.3.0 +Faker==23.1.0 +filelock==3.0.12 +flake8==7.0.0 +future==0.18.3 +google-api-core==1.32.0 +google-auth==1.30.0 +google-cloud-core==1.7.2 +google-cloud-storage==1.38.0 +google-crc32c==1.3.0 +google-resumable-media==1.3.3 +googleapis-common-protos==1.53.0 +gunicorn==22.0.0 +h11==0.14.0 +html5lib==1.1 +idna==3.7 +importlib_metadata==8.5.0 +inflection==0.5.1 +iniconfig==2.0.0 +isort==5.4.2 +Jinja2==3.1.4 +jmespath==0.10.0 +jsonschema==4.23.0 +jsonschema-specifications==2023.12.1 +lazy-object-proxy==1.4.3 +lxml==5.3.0 +markdown-it-py==3.0.0 +MarkupSafe==2.1.5 +mccabe==0.7.0 +mdurl==0.1.2 +oauthlib==3.2.2 +oscrypto==1.3.0 +outcome==1.3.0.post0 +packaging==21.0 +pillow>=10.3.0 +platformdirs==4.3.6 +pluggy==1.4.0 +protobuf==3.18.3 +psycopg2-binary==2.9.9 +pyasn1==0.4.8 +pyasn1-modules==0.2.8 +pycodestyle==2.11.1 +pycparser==2.22 +pydantic==2.9.2 +pydantic_core==2.23.4 +pyflakes==3.2.0 +Pygments==2.18.0 +pyHanko==0.25.1 +pyhanko-certvalidator==0.26.3 +PyJWT==2.9.0 +pyleniumio==1.21.0 +pylint==3.0.3 +pylint-django==2.3.0 +pylint-plugin-utils==0.6 +pyparsing==2.4.7 +pypdf==4.0.2 +PyPDF2==1.27.9 +PySocks==1.7.1 +pytest==8.0.0 +pytest-cov==4.1.0 +pytest-django==4.7.0 +pytest-html==4.1.1 +pytest-metadata==3.0.0 +pytest-runner==5.3.1 +pytest-xdist==3.6.1 +python-bidi==0.4.2 +python-dateutil==2.8.1 +python3-openid==3.2.0 +pytz==2020.1 +PyYAML==6.0.1 +qrcode==7.4.2 +referencing==0.35.1 +reportlab==3.6.13 +requests>=2.32.0 +requests-oauthlib==1.3.1 +rich==13.9.1 +rpds-py==0.20.0 +rsa==4.7.2 +s3transfer==0.6.0 +selenium==4.25.0 +shellingham==1.5.4 +six==1.15.0 +sniffio==1.3.1 +sortedcontainers==2.4.0 +sqlparse==0.5.0 +svglib==1.5.1 +tinycss2==1.3.0 +toml==0.10.1 +tomli==2.0.2 +tomlkit==0.13.2 +trio==0.26.2 +trio-websocket==0.11.1 +typer==0.9.4 +typing_extensions==4.12.2 +tzdata==2024.2 +tzlocal==5.2 +Unidecode==1.3.6 +uritemplate==4.1.1 +uritools==4.0.2 +urllib3>=1.26.19 +virtualenv==20.0.23 +webencodings==0.5.1 +websocket-client==1.8.0 +whitenoise==5.2.0 +wrapt==1.12.1 +wsproto==1.2.0 +xhtml2pdf==0.2.11 +yapf==0.40.2 +zipp==3.20.2 diff --git a/researchers/decorators.py b/researchers/decorators.py index 359398c10..c2d804736 100644 --- a/researchers/decorators.py +++ b/researchers/decorators.py @@ -7,7 +7,11 @@ from .utils import checkif_user_researcher -def is_researcher(pk_arg_name='pk'): +def get_researcher(pk_arg_name='pk'): + """ + * Injects the researcher object into the request context + * Removes the pk_arg_name being used from the request context + """ def decorator(view_func): @wraps(view_func) def _wrapped_view(request, *args, **kwargs): diff --git a/researchers/forms.py b/researchers/forms.py index f44df98a8..26ed1dc52 100644 --- a/researchers/forms.py +++ b/researchers/forms.py @@ -2,33 +2,49 @@ from .models import Researcher from django.utils.translation import gettext_lazy as _ + class ConnectResearcherForm(forms.ModelForm): class Meta: model = Researcher fields = ['primary_institution', 'description', 'website'] widgets = { 'primary_institution': forms.TextInput(attrs={'class': 'w-100'}), - 'description': forms.Textarea(attrs={'class': 'w-100', 'rows': 2,}), + 'description': forms.Textarea(attrs={'class': 'w-100', 'rows': 2}), 'website': forms.TextInput(attrs={'class': 'w-100'}), } + class UpdateResearcherForm(forms.ModelForm): PRIVACY = ( ('True', 'Yes: Users of the hub can contact me using this email'), ('False', 'No: Users of the hub can not contact me using this email'), ) - contact_email_public = forms.ChoiceField(label=_('Can this email be used for users of the Local Contexts Hub to contact you?'), choices=PRIVACY, initial='False', widget=forms.RadioSelect(attrs={'class': 'ul-no-bullets'})) + contact_email_public = forms.ChoiceField( + label=_('Can this email be used for users of the Local Contexts Hub to contact you?'), + choices=PRIVACY, + initial='False', + widget=forms.RadioSelect(attrs={'class': 'ul-no-bullets'}) + ) class Meta: model = Researcher - fields = ['primary_institution', 'contact_email', 'contact_email_public', 'website', 'description', 'image'] + fields = [ + 'primary_institution', + 'contact_email', + 'contact_email_public', + 'website', + 'description', + 'image' + ] exclude = ['user'] widgets = { 'contact_email': forms.TextInput(attrs={'class': 'w-100'}), 'website': forms.TextInput(attrs={'class': 'w-100'}), 'primary_institution': forms.TextInput(attrs={'class': 'w-100'}), - 'description': forms.Textarea(attrs={'class': 'w-100', 'rows': 3,}), - 'image': forms.ClearableFileInput(attrs={'class': 'w-100 hide', 'id': 'researcherImgUploadBtn', 'onchange': 'showFile()'}), - - } \ No newline at end of file + 'description': forms.Textarea(attrs={'class': 'w-100', 'rows': 3}), + 'image': forms.ClearableFileInput(attrs={ + 'class': 'w-100 hide', + 'id': 'researcherImgUploadBtn', + 'onchange': 'showFile()'}), + } diff --git a/researchers/migrations/0037_researcher_is_subscribed.py b/researchers/migrations/0037_researcher_is_subscribed.py new file mode 100644 index 000000000..af1be541b --- /dev/null +++ b/researchers/migrations/0037_researcher_is_subscribed.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-03-27 07:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('researchers', '0036_alter_researcher_id'), + ] + + operations = [ + migrations.AddField( + model_name='researcher', + name='is_subscribed', + field=models.BooleanField(blank=True, default=False, null=True), + ), + ] diff --git a/researchers/migrations/0038_researcher_is_submitted_and_more.py b/researchers/migrations/0038_researcher_is_submitted_and_more.py new file mode 100644 index 000000000..0bdaad12c --- /dev/null +++ b/researchers/migrations/0038_researcher_is_submitted_and_more.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.11 on 2024-04-23 15:27 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("researchers", "0037_researcher_is_subscribed"), + ] + + operations = [ + migrations.AddField( + model_name="researcher", + name="is_submitted", + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name="researcher", + name="is_subscribed", + field=models.BooleanField(default=False), + ), + ] diff --git a/researchers/migrations/0039_remove_researcher_is_submitted.py b/researchers/migrations/0039_remove_researcher_is_submitted.py new file mode 100644 index 000000000..f9d3277ff --- /dev/null +++ b/researchers/migrations/0039_remove_researcher_is_submitted.py @@ -0,0 +1,16 @@ +# Generated by Django 4.2.11 on 2024-05-28 14:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("researchers", "0038_researcher_is_submitted_and_more"), + ] + + operations = [ + migrations.RemoveField( + model_name="researcher", + name="is_submitted", + ), + ] diff --git a/researchers/migrations/0040_researcher_show_sp_connection.py b/researchers/migrations/0040_researcher_show_sp_connection.py new file mode 100644 index 000000000..468f71fc4 --- /dev/null +++ b/researchers/migrations/0040_researcher_show_sp_connection.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-08-23 16:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('researchers', '0039_remove_researcher_is_submitted'), + ] + + operations = [ + migrations.AddField( + model_name='researcher', + name='show_sp_connection', + field=models.BooleanField(default=True), + ), + ] diff --git a/researchers/migrations/0041_researcher_sp_privacy.py b/researchers/migrations/0041_researcher_sp_privacy.py new file mode 100644 index 000000000..a5a47eceb --- /dev/null +++ b/researchers/migrations/0041_researcher_sp_privacy.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-09-11 17:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('researchers', '0040_researcher_show_sp_connection'), + ] + + operations = [ + migrations.AddField( + model_name='researcher', + name='sp_privacy', + field=models.CharField(choices=[('public', 'Public/Contributor'), ('all', 'All')], default='all', max_length=20), + ), + ] diff --git a/researchers/models.py b/researchers/models.py index 2783ffa94..b6a29a0ad 100644 --- a/researchers/models.py +++ b/researchers/models.py @@ -4,12 +4,19 @@ import uuid import os + def researcher_img_path(self, filename): ext = filename.split('.')[-1] filename = "%s.%s" % (str(uuid.uuid4()), ext) - return os.path.join('users/researcher-images', filename) + return os.path.join('users/researcher-images', filename) + class Researcher(models.Model): + PRIVACY_LEVEL = ( + ('public', 'Public/Contributor'), + ('all', 'All'), + ) + user = models.ForeignKey(User, null=True, on_delete=models.CASCADE) orcid = models.CharField(max_length=300, null=True, blank=True) image = models.ImageField(upload_to=researcher_img_path, blank=True, null=True) @@ -20,12 +27,16 @@ class Researcher(models.Model): primary_institution = models.CharField(max_length=250, null=True, blank=True) orcid_auth_token = models.TextField(null=True, blank=True) date_connected = models.DateTimeField(auto_now_add=True, null=True) + is_subscribed = models.BooleanField(default=False) + + show_sp_connection = models.BooleanField(default=True, null=True) + sp_privacy = models.CharField(max_length=20, default='all', choices=PRIVACY_LEVEL, null=True) def get_projects(self): - return self.researcher_created_project.filter(researcher=self).exists() + return self.researcher_created_project.filter(researcher=self).exists() def __str__(self): return str(self.user) - + class Meta: indexes = [models.Index(fields=['id', 'user', 'image'])] diff --git a/researchers/signals.py b/researchers/signals.py index eb1d9066b..9a01ecdb2 100644 --- a/researchers/signals.py +++ b/researchers/signals.py @@ -4,16 +4,19 @@ from django.contrib.auth.models import User from helpers.emails import manage_researcher_mailing_list + def remove_email_on_delete(email): if email: manage_researcher_mailing_list(email, False) + @receiver(pre_delete, sender=User) def remove_from_mailing_list_on_delete_user(sender, instance, *args, **kwargs): if Researcher.objects.filter(user=instance).exists(): email = Researcher.objects.get(user=instance).contact_email or instance.email remove_email_on_delete(email) + @receiver(pre_delete, sender=Researcher) def remove_from_mailing_list_on_delete_researcher(sender, instance, *args, **kwargs): email = instance.contact_email or instance.user.email diff --git a/researchers/templatetags/custom_researcher_tags.py b/researchers/templatetags/custom_researcher_tags.py index 8c607572f..d964cf4fb 100644 --- a/researchers/templatetags/custom_researcher_tags.py +++ b/researchers/templatetags/custom_researcher_tags.py @@ -1,30 +1,9 @@ from django import template -from helpers.models import Notice -from projects.models import ProjectContributors, ProjectCreator +from projects.models import ProjectContributors register = template.Library() -# @register.simple_tag -# def anchor(url_name, section_id, researcher_id): -# return reverse(url_name, kwargs={'pk': researcher_id}) + "#project-unique-" + str(section_id) - -@register.simple_tag -def get_notices_count(researcher): - return Notice.objects.filter(researcher=researcher).count() - -@register.simple_tag -def get_labels_count(researcher): - count = 0 - for instance in ProjectCreator.objects.select_related('project').prefetch_related('project__bc_labels', 'project__tk_labels').filter(researcher=researcher): - if instance.project.has_labels(): - count += 1 - return count @register.simple_tag def researcher_contributing_projects(researcher): return ProjectContributors.objects.select_related('project').filter(researchers=researcher) - -@register.simple_tag -def connections_count(researcher): - contributor_ids = researcher.contributing_researchers.exclude(communities__id=None).values_list('communities__id', flat=True) - return len(contributor_ids) diff --git a/researchers/urls.py b/researchers/urls.py index 7bd5e5d18..a38bd4f29 100644 --- a/researchers/urls.py +++ b/researchers/urls.py @@ -2,32 +2,85 @@ from . import views urlpatterns = [ + # Creating/Joining Accounts path('preparation-step/', views.preparation_step, name="prep-researcher"), path('connect-researcher/', views.connect_researcher, name="connect-researcher"), path('connect-orcid/', views.connect_orcid, name="connect-orcid"), path('disconnect-orcid/', views.disconnect_orcid, name="disconnect-orcid"), - + # Public view path('view//', views.public_researcher_view, name="public-researcher"), path('embed//', views.embed_otc_notice, name="embed-notice-researcher"), + # Settings path('update/', views.update_researcher, name="update-researcher"), + path('preferences//', views.account_preferences, name="preferences-researcher"), + path('api-key//', views.api_keys, name="researcher-api-key"), + path( + 'connect-service-provider//', + views.connect_service_provider, + name="researcher-connect-service-provider" + ), + path( + 'subscription-form//', + views.create_researcher_subscription, + name="researcher-create-subscription-form" + ), + # Notices path('notices/', views.researcher_notices, name="researcher-notices"), - path('notices/otc/delete///', views.delete_otc_notice, name="researcher-delete-otc"), + path( + 'notices/otc/delete///', + views.delete_otc_notice, + name="researcher-delete-otc" + ), + # Projects: View path('projects/', views.researcher_projects, name="researcher-projects"), - path('projects/create-project///', views.create_project, name="researcher-create-project"), - path('projects/create-project///', views.create_project, name="researcher-create-project"), - path('projects/create-project//', views.create_project, name="researcher-create-project"), - - path('projects/edit-project///', views.edit_project, name="researcher-edit-project"), - path('projects/actions///', views.project_actions, name="researcher-project-actions"), - path('projects/delete-project///', views.delete_project, name="researcher-delete-project"), - path('projects/archive-project//', views.archive_project, name="researcher-archive-project"), + # Projects: Create + path( + 'projects/create-project///', + views.create_project, name="researcher-create-project" + ), + path( + 'projects/create-project///', + views.create_project, + name="researcher-create-project" + ), + path( + 'projects/create-project//', + views.create_project, + name="researcher-create-project" + ), - path('projects/unlink///', views.unlink_project, name="researcher-unlink-project"), + # Projects: Edit + path( + 'projects/edit-project///', + views.edit_project, + name="researcher-edit-project" + ), + path( + 'projects/actions///', + views.project_actions, + name="researcher-project-actions" + ), + path( + 'projects/delete-project///', + views.delete_project, + name="researcher-delete-project" + ), + path( + 'projects/archive-project//', + views.archive_project, + name="researcher-archive-project" + ), + path( + 'projects/unlink///', + views.unlink_project, + name="researcher-unlink-project" + ), + # Connections path('connections//', views.connections, name="researcher-connections"), -] \ No newline at end of file +] diff --git a/researchers/utils.py b/researchers/utils.py index c701e6ba0..e7aaf0d14 100644 --- a/researchers/utils.py +++ b/researchers/utils.py @@ -1,4 +1,12 @@ from .models import Researcher +from helpers.utils import handle_confirmation_and_subscription +from helpers.emails import (send_researcher_email, send_hub_admins_account_creation_email, + manage_researcher_mailing_list) +from helpers.models import HubActivity +from django.db import transaction +from django.contrib import messages +from django.shortcuts import redirect + def is_user_researcher(user): if Researcher.objects.filter(user=user).exists(): @@ -6,6 +14,7 @@ def is_user_researcher(user): else: return None + def checkif_user_researcher(current_researcher, user): if Researcher.objects.filter(user=user).exists(): researcher = Researcher.objects.select_related('user').get(user=user) @@ -14,4 +23,43 @@ def checkif_user_researcher(current_researcher, user): else: return False else: - return False \ No newline at end of file + return False + + +def handle_researcher_creation(request, subscription_form, form, orcid_id, orcid_token, env): + try: + with transaction.atomic(): + data = form.save(commit=False) + data.user = request.user + data.orcid_auth_token = orcid_token + data.orcid = orcid_id + data.save() + if env != 'SANDBOX': + handle_confirmation_and_subscription(request, subscription_form, data, env) + + # Mark current user as researcher + request.user.user_profile.is_researcher = True + request.user.user_profile.save() + + # sends one email to the account creator + # and one to either site admin or support + send_researcher_email(request) + send_hub_admins_account_creation_email(request, data) + + # Add researcher to mailing list + if env == 'PROD': + manage_researcher_mailing_list(request.user.email, True) + + # Adds activity to Hub Activity + HubActivity.objects.create( + action_user_id=request.user.id, + action_type="New Researcher" + ) + except Exception as e: + messages.add_message( + request, + messages.ERROR, + f"An unexpected error occured: {str(e)}" + " Please contact support@localcontexts.org." + ) + return redirect('dashboard') diff --git a/researchers/views.py b/researchers/views.py index 88c584565..49c035967 100644 --- a/researchers/views.py +++ b/researchers/views.py @@ -1,31 +1,68 @@ +from datetime import timezone from django.shortcuts import render, redirect from django.contrib.auth.decorators import login_required from django.contrib import messages from django.http import Http404 +from django.db import transaction from django.db.models import Q from itertools import chain +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode from localcontexts.utils import dev_prod_or_local -from projects.utils import * -from helpers.utils import * +from projects.utils import ( + return_project_labels_by_community, add_to_contributors, return_project_search_results, + paginate + ) +from helpers.utils import ( + crud_notices, create_or_update_boundary, get_notice_defaults, get_notice_translations, + check_subscription, validate_recaptcha, form_initiation, get_certified_service_providers, + handle_confirmation_and_subscription, + ) from accounts.utils import get_users_name -from notifications.utils import send_action_notification_to_project_contribs +from notifications.utils import ( + send_action_notification_to_project_contribs, send_simple_action_notification, + delete_action_notification, +) +from api.models import AccountAPIKey from communities.models import Community +from institutions.models import Institution from notifications.models import ActionNotification -from helpers.models import * -from projects.models import * - -from projects.forms import * +from accounts.models import ServiceProviderConnections, Subscription +from helpers.models import ( + OpenToCollaborateNoticeURL, HubActivity, ProjectStatus, EntitiesNotified, ProjectComment, + Notice + ) +from projects.models import ( + ProjectContributors, Project, ProjectActivity, ProjectArchived, ProjectCreator, + ProjectPerson + ) +from serviceproviders.models import ServiceProvider + +from projects.forms import ( + CreateProjectForm, ProjectPersonFormset, ProjectPersonFormsetInline, EditProjectForm + ) from helpers.forms import ProjectCommentForm, OpenToCollaborateNoticeURLForm -from accounts.forms import ContactOrganizationForm -from .decorators import is_researcher - -from helpers.emails import * +from accounts.forms import ( + ContactOrganizationForm, + SubscriptionForm +) +from api.forms import APIKeyGeneratorForm + +from helpers.emails import ( + send_email_notice_placed, + send_action_notification_project_status, + send_project_person_email, + send_contact_email + ) from maintenance_mode.decorators import force_maintenance_mode_off + +from .decorators import get_researcher from .models import Researcher -from .forms import * -from .utils import * +from .forms import UpdateResearcherForm, ConnectResearcherForm +from .utils import checkif_user_researcher, handle_researcher_creation, is_user_researcher + @login_required(login_url='login') def preparation_step(request): @@ -35,69 +72,98 @@ def preparation_step(request): 'researcher': researcher, 'environment': environment } - return render(request, 'accounts/preparation.html', context) + if not Researcher.objects.filter(user=request.user): + return render(request, 'accounts/preparation.html', context) + else: + researcher_id = Researcher.objects.get(user=request.user).id + return redirect('researcher-notices', researcher_id) + @login_required(login_url='login') def connect_researcher(request): researcher = is_user_researcher(request.user) form = ConnectResearcherForm(request.POST or None) + user_form = form_initiation(request) + env = dev_prod_or_local(request.get_host()) - + if not researcher: if request.method == "POST": - if form.is_valid(): + if form.is_valid() and user_form.is_valid() and validate_recaptcha(request): + mutable_post_data = request.POST.copy() + subscription_data = { + "first_name": user_form.cleaned_data['first_name'], + "last_name": user_form.cleaned_data['last_name'], + "email": request.user._wrapped.email, + "inquiry_type": "Subscription", + "account_type": "researcher_account", + "organization_name": get_users_name(request.user), + } + mutable_post_data.update(subscription_data) + subscription_form = SubscriptionForm(mutable_post_data) orcid_id = request.POST.get('orcidId') orcid_token = request.POST.get('orcidIdToken') - - data = form.save(commit=False) - data.user = request.user - data.orcid_auth_token = orcid_token - data.orcid = orcid_id - data.save() - - # Mark current user as researcher - request.user.user_profile.is_researcher = True - request.user.user_profile.save() - - # sends one email to the account creator - # and one to either site admin or support - send_researcher_email(request) - send_hub_admins_account_creation_email(request, data) - # Add researcher to mailing list - if env == 'PROD': - manage_researcher_mailing_list(request.user.email, True) - - # Adds activity to Hub Activity - HubActivity.objects.create( - action_user_id=request.user.id, - action_type="New Researcher" - ) - messages.add_message(request, messages.INFO, - 'Your researcher account has been created.') - return redirect('dashboard') - context = {'form': form, 'env': env} + if subscription_form.is_valid(): + handle_researcher_creation( + request, + subscription_form, + form, + orcid_id, + orcid_token, + env + ) + return redirect('dashboard') + else: + messages.add_message( + request, + messages.ERROR, + "Something went wrong. Please Try again later.", + ) + return redirect('dashboard') + context = {'form': form, 'env': env, 'user_form': user_form} return render(request, 'researchers/connect-researcher.html', context) else: return redirect('researcher-notices', researcher.id) + def public_researcher_view(request, pk): try: researcher = Researcher.objects.get(id=pk) # Do notices exist - bcnotice = Notice.objects.filter(researcher=researcher, notice_type='biocultural').exists() - tknotice = Notice.objects.filter(researcher=researcher, notice_type='traditional_knowledge').exists() - attrnotice = Notice.objects.filter(researcher=researcher, notice_type='attribution_incomplete').exists() + bcnotice = Notice.objects.filter( + researcher=researcher, + notice_type='biocultural' + ).exists() + tknotice = Notice.objects.filter( + researcher=researcher, + notice_type='traditional_knowledge' + ).exists() + attrnotice = Notice.objects.filter( + researcher=researcher, + notice_type='attribution_incomplete' + ).exists() otc_notices = OpenToCollaborateNoticeURL.objects.filter(researcher=researcher) projects_list = list(chain( - researcher.researcher_created_project.all().values_list('project__unique_id', flat=True), # researcher created project ids - researcher.contributing_researchers.all().values_list('project__unique_id', flat=True), # projects where researcher is contributor + researcher.researcher_created_project.all().values_list( + 'project__unique_id', flat=True + ), # researcher created project ids + researcher.contributing_researchers.all().values_list( + 'project__unique_id', flat=True + ), # projects where researcher is contributor )) - project_ids = list(set(projects_list)) # remove duplicate ids - archived = ProjectArchived.objects.filter(project_uuid__in=project_ids, researcher_id=researcher.id, archived=True).values_list('project_uuid', flat=True) # check ids to see if they are archived - projects = Project.objects.select_related('project_creator').filter(unique_id__in=project_ids, project_privacy='Public').exclude(unique_id__in=archived).order_by('-date_modified') + project_ids = list(set(projects_list)) # remove duplicate ids + archived = ProjectArchived.objects.filter( + project_uuid__in=project_ids, + researcher_id=researcher.id, + archived=True + ).values_list('project_uuid', flat=True) # check ids to see if they are archived + projects = Project.objects.select_related('project_creator').filter( + unique_id__in=project_ids, + project_privacy='Public' + ).exclude(unique_id__in=archived).order_by('-date_modified') if request.user.is_authenticated: form = ContactOrganizationForm(request.POST or None) @@ -111,20 +177,31 @@ def public_researcher_view(request, pk): message = form.cleaned_data['message'] to_email = researcher.contact_email - send_contact_email(request, to_email, from_name, from_email, message, researcher) + send_contact_email( + request, + to_email, + from_name, + from_email, + message, + researcher + ) messages.add_message(request, messages.SUCCESS, 'Message sent!') return redirect('public-researcher', researcher.id) else: if not form.data['message']: - messages.add_message(request, messages.ERROR, 'Unable to send an empty message.') + messages.add_message( + request, + messages.ERROR, + 'Unable to send an empty message.' + ) return redirect('public-researcher', researcher.id) else: messages.add_message(request, messages.ERROR, 'Something went wrong.') return redirect('public-researcher', researcher.id) else: - context = { + context = { 'researcher': researcher, - 'projects' : projects, + 'projects': projects, 'bcnotice': bcnotice, 'tknotice': tknotice, 'attrnotice': attrnotice, @@ -133,18 +210,18 @@ def public_researcher_view(request, pk): } return render(request, 'public.html', context) - context = { + context = { 'researcher': researcher, - 'projects' : projects, + 'projects': projects, 'bcnotice': bcnotice, 'tknotice': tknotice, 'attrnotice': attrnotice, 'otc_notices': otc_notices, - 'form': form, + 'form': form, 'env': dev_prod_or_local(request.get_host()), } return render(request, 'public.html', context) - except: + except Researcher.DoesNotExist: raise Http404() @@ -153,6 +230,7 @@ def connect_orcid(request): researcher = Researcher.objects.get(user=request.user) return redirect('update-researcher', researcher.id) + @login_required(login_url='login') def disconnect_orcid(request): researcher = Researcher.objects.get(user=request.user) @@ -163,9 +241,10 @@ def disconnect_orcid(request): @login_required(login_url='login') -@is_researcher() +@get_researcher() def update_researcher(request, researcher): env = dev_prod_or_local(request.get_host()) + if request.method == 'POST': update_form = UpdateResearcherForm(request.POST, request.FILES, instance=researcher) @@ -196,22 +275,47 @@ def update_researcher(request, researcher): 'user_can_view': True, 'env': env } - return render(request, 'researchers/update-researcher.html', context) + return render(request, 'account_settings_pages/_update-account.html', context) + @login_required(login_url='login') -@is_researcher() +@get_researcher(pk_arg_name='pk') def researcher_notices(request, researcher): - urls = OpenToCollaborateNoticeURL.objects.filter(researcher=researcher).values_list('url', 'name', 'id') + notify_restricted_message = False + create_restricted_message = False + + try: + subscription = Subscription.objects.get(researcher=researcher.id) + not_approved_download_notice = None + not_approved_shared_notice = None + except Subscription.DoesNotExist: + subscription = None + not_approved_download_notice = ( + "Your researcher account needs to be subscribed in order to download this Notice." + ) + not_approved_shared_notice = ( + "Your researcher account needs to be subscribed in order to share this Notice." + ) + + urls = OpenToCollaborateNoticeURL.objects.filter(researcher=researcher).values_list( + 'url', + 'name', + 'id' + ) form = OpenToCollaborateNoticeURLForm(request.POST or None) - if dev_prod_or_local(request.get_host()) == 'SANDBOX': + if dev_prod_or_local(request.get_host()) == "SANDBOX": is_sandbox = True otc_download_perm = 0 - download_notice_on_sandbox = "Download of Notices is not available on the sandbox site." - share_notice_on_sandbox = "Sharing of Notices is not available on the sandbox site." + download_notice_on_sandbox = ( + "Download of Notices is not available on the sandbox site." + ) + share_notice_on_sandbox = ( + "Sharing of Notices is not available on the sandbox site." + ) else: is_sandbox = False - otc_download_perm = 1 + otc_download_perm = 1 if researcher.is_subscribed else 0 download_notice_on_sandbox = None share_notice_on_sandbox = None @@ -225,7 +329,7 @@ def researcher_notices(request, researcher): action_user_id=request.user.id, action_type="Engagement Notice Added", project_id=data.id, - action_account_type = 'researcher' + action_account_type='researcher' ) return redirect('researcher-notices', researcher.id) @@ -235,9 +339,14 @@ def researcher_notices(request, researcher): 'form': form, 'urls': urls, 'otc_download_perm': otc_download_perm, + 'notify_restricted_message': notify_restricted_message, + 'create_restricted_message': create_restricted_message, 'is_sandbox': is_sandbox, + 'not_approved_download_notice': not_approved_download_notice, 'download_notice_on_sandbox': download_notice_on_sandbox, + 'not_approved_shared_notice': not_approved_shared_notice, 'share_notice_on_sandbox': share_notice_on_sandbox, + 'subscription': subscription, } return render(request, 'researchers/notices.html', context) @@ -251,8 +360,18 @@ def delete_otc_notice(request, researcher_id, notice_id): @login_required(login_url='login') -@is_researcher() +@get_researcher(pk_arg_name='pk') def researcher_projects(request, researcher): + create_restricted_message = False + try: + subscription = Subscription.objects.get(researcher=researcher.id) + except Subscription.DoesNotExist: + subscription = None + if not researcher.is_subscribed: + create_restricted_message = ( + 'The account must be subscribed before a Project can be created' + ) + bool_dict = { 'has_labels': False, 'has_notices': False, @@ -267,13 +386,34 @@ def researcher_projects(request, researcher): } projects_list = list(chain( - researcher.researcher_created_project.all().values_list('project__unique_id', flat=True), # researcher projects - researcher.researchers_notified.all().values_list('project__unique_id', flat=True), # projects researcher has been notified of - researcher.contributing_researchers.all().values_list('project__unique_id', flat=True), # projects where researcher is contributor + researcher.researcher_created_project.all().values_list( + 'project__unique_id', + flat=True + ), # researcher projects + researcher.researchers_notified.all().values_list( + 'project__unique_id', + flat=True + ), # projects researcher has been notified of + researcher.contributing_researchers.all().values_list( + 'project__unique_id', + flat=True + ), # projects where researcher is contributor )) - project_ids = list(set(projects_list)) # remove duplicate ids - archived = ProjectArchived.objects.filter(project_uuid__in=project_ids, researcher_id=researcher.id, archived=True).values_list('project_uuid', flat=True) # check ids to see if they are archived - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids).exclude(unique_id__in=archived).order_by('-date_added') + project_ids = list(set(projects_list)) # remove duplicate ids + archived = ProjectArchived.objects.filter( + project_uuid__in=project_ids, + researcher_id=researcher.id, + archived=True + ).values_list( + 'project_uuid', + flat=True + ) # check ids to see if they are archived + projects = Project.objects.select_related('project_creator').prefetch_related( + 'bc_labels', + 'tk_labels' + ).filter( + unique_id__in=project_ids + ).exclude(unique_id__in=archived).order_by('-date_added') sort_by = request.GET.get('sort') @@ -281,52 +421,135 @@ def researcher_projects(request, researcher): return redirect('researcher-projects', researcher.id) elif sort_by == 'has_labels': - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids - ).exclude(unique_id__in=archived).exclude(bc_labels=None).order_by('-date_added') | Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids - ).exclude(unique_id__in=archived).exclude(tk_labels=None).order_by('-date_added') + projects = Project.objects.select_related('project_creator').prefetch_related( + 'bc_labels', + 'tk_labels' + ).filter( + unique_id__in=project_ids + ).exclude( + unique_id__in=archived + ).exclude( + bc_labels=None + ).order_by('-date_added') | Project.objects.select_related( + 'project_creator' + ).prefetch_related( + 'bc_labels', + 'tk_labels' + ).filter( + unique_id__in=project_ids + ).exclude( + unique_id__in=archived + ).exclude(tk_labels=None).order_by('-date_added') bool_dict['has_labels'] = True elif sort_by == 'has_notices': - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids, tk_labels=None, bc_labels=None).exclude(unique_id__in=archived).order_by('-date_added') + projects = Project.objects.select_related('project_creator').prefetch_related( + 'bc_labels', + 'tk_labels' + ).filter( + unique_id__in=project_ids, + tk_labels=None, + bc_labels=None + ).exclude(unique_id__in=archived).order_by('-date_added') bool_dict['has_notices'] = True elif sort_by == 'created': - created_projects = researcher.researcher_created_project.all().values_list('project__unique_id', flat=True) - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=created_projects).exclude(unique_id__in=archived).order_by('-date_added') + created_projects = researcher.researcher_created_project.all().values_list( + 'project__unique_id', + flat=True + ) + projects = Project.objects.select_related('project_creator').prefetch_related( + 'bc_labels', + 'tk_labels' + ).filter( + unique_id__in=created_projects + ).exclude( + unique_id__in=archived).order_by('-date_added') bool_dict['created'] = True elif sort_by == 'contributed': - contrib = researcher.contributing_researchers.all().values_list('project__unique_id', flat=True) + contrib = researcher.contributing_researchers.all().values_list( + 'project__unique_id', + flat=True + ) projects_list = list(chain( - researcher.researcher_created_project.all().values_list('project__unique_id', flat=True), # check researcher created projects - ProjectArchived.objects.filter(project_uuid__in=contrib, researcher_id=researcher.id, archived=True).values_list('project_uuid', flat=True) # check ids to see if they are archived + researcher.researcher_created_project.all().values_list( + 'project__unique_id', + flat=True + ), # check researcher created projects + ProjectArchived.objects.filter( + project_uuid__in=contrib, + researcher_id=researcher.id, + archived=True + ).values_list( + 'project_uuid', + flat=True + ) # check ids to see if they are archived )) - project_ids = list(set(projects_list)) # remove duplicate ids - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=contrib).exclude(unique_id__in=project_ids).order_by('-date_added') + project_ids = list(set(projects_list)) # remove duplicate ids + projects = Project.objects.select_related('project_creator').prefetch_related( + 'bc_labels', + 'tk_labels' + ).filter( + unique_id__in=contrib + ).exclude(unique_id__in=project_ids).order_by('-date_added') bool_dict['contributed'] = True elif sort_by == 'archived': - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=archived).order_by('-date_added') + projects = Project.objects.select_related('project_creator').prefetch_related( + 'bc_labels', + 'tk_labels' + ).filter(unique_id__in=archived).order_by('-date_added') bool_dict['is_archived'] = True elif sort_by == 'title_az': projects = projects.order_by('title') bool_dict['title_az'] = True + elif sort_by == 'archived': + projects = Project.objects.select_related('project_creator').prefetch_related( + 'bc_labels', + 'tk_labels' + ).filter(unique_id__in=archived).order_by('-date_added') + bool_dict['is_archived'] = True + elif sort_by == 'visibility_public': - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids, project_privacy='Public').exclude(unique_id__in=archived).order_by('-date_added') + projects = Project.objects.select_related('project_creator').prefetch_related( + 'bc_labels', + 'tk_labels' + ).filter( + unique_id__in=project_ids, + project_privacy='Public' + ).exclude(unique_id__in=archived).order_by('-date_added') bool_dict['visibility_public'] = True elif sort_by == 'visibility_contributor': - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids, project_privacy='Contributor').exclude(unique_id__in=archived).order_by('-date_added') + projects = Project.objects.select_related('project_creator').prefetch_related( + 'bc_labels', + 'tk_labels' + ).filter( + unique_id__in=project_ids, + project_privacy='Contributor' + ).exclude(unique_id__in=archived).order_by('-date_added') bool_dict['visibility_contributor'] = True elif sort_by == 'visibility_private': - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids, project_privacy='Private').exclude(unique_id__in=archived).order_by('-date_added') + projects = Project.objects.select_related('project_creator').prefetch_related( + 'bc_labels', + 'tk_labels' + ).filter( + unique_id__in=project_ids, + project_privacy='Private' + ).exclude(unique_id__in=archived).order_by('-date_added') bool_dict['visibility_private'] = True elif sort_by == 'date_modified': - projects = Project.objects.select_related('project_creator').prefetch_related('bc_labels', 'tk_labels').filter(unique_id__in=project_ids).exclude(unique_id__in=archived).order_by('-date_modified') + projects = Project.objects.select_related('project_creator').prefetch_related( + 'bc_labels', + 'tk_labels' + ).filter( + unique_id__in=project_ids + ).exclude(unique_id__in=archived).order_by('-date_modified') bool_dict['date_modified'] = True page = paginate(request, projects, 10) @@ -342,19 +565,34 @@ def researcher_projects(request, researcher): 'items': page, 'results': results, 'bool_dict': bool_dict, + 'create_restricted_message': create_restricted_message, + 'subscription': subscription, } return render(request, 'researchers/projects.html', context) # Create Project @login_required(login_url='login') -@is_researcher() +@get_researcher(pk_arg_name='pk') def create_project(request, researcher, source_proj_uuid=None, related=None): name = get_users_name(request.user) notice_defaults = get_notice_defaults() notice_translations = get_notice_translations() - if request.method == "POST": + if check_subscription( + request, + 'researcher', + researcher.id + ) and dev_prod_or_local( + request.get_host() + ) != 'SANDBOX': + return redirect('researcher-projects', researcher.id) + + subscription = Subscription.objects.get(researcher=researcher) + if request.method == "GET": + form = CreateProjectForm(request.POST or None) + formset = ProjectPersonFormset(queryset=ProjectPerson.objects.none()) + elif request.method == "POST": form = CreateProjectForm(request.POST) formset = ProjectPersonFormset(request.POST) @@ -362,8 +600,13 @@ def create_project(request, researcher, source_proj_uuid=None, related=None): data = form.save(commit=False) data.project_creator = request.user + if subscription.project_count > 0: + subscription.project_count -= 1 + subscription.save() # Define project_page field - data.project_page = f'{request.scheme}://{request.get_host()}/projects/{data.unique_id}' + data.project_page = ( + f'{request.scheme}://{request.get_host()}/projects/{data.unique_id}' + ) # Handle multiple urls, save as array project_links = request.POST.getlist('project_urls') @@ -379,7 +622,12 @@ def create_project(request, researcher, source_proj_uuid=None, related=None): if source_proj_uuid and not related: data.source_project_uuid = source_proj_uuid data.save() - ProjectActivity.objects.create(project=data, activity=f'Sub Project "{data.title}" was added to Project by {name} | Researcher') + ProjectActivity.objects.create( + project=data, + activity=( + f'Sub Project "{data.title}" was added to Project by {name} | Researcher' + ) + ) if source_proj_uuid and related: source = Project.objects.get(unique_id=source_proj_uuid) @@ -388,18 +636,31 @@ def create_project(request, researcher, source_proj_uuid=None, related=None): source.save() data.save() - ProjectActivity.objects.create(project=data, activity=f'Project "{source.title}" was connected to Project by {name} | Researcher') - ProjectActivity.objects.create(project=source, activity=f'Project "{data.title}" was connected to Project by {name} | Researcher') + ProjectActivity.objects.create( + project=data, + activity=( + f'Project "{source.title}" was connected to Project by {name}' + ' | Researcher' + ) + ) + ProjectActivity.objects.create( + project=source, + activity=( + f'Project "{data.title}" was connected to Project by {name}' + ' | Researcher' + ) + ) # Create activity - ProjectActivity.objects.create(project=data, activity=f'Project was created by {name} | Researcher') + ProjectActivity.objects.create( + project=data, activity=f'Project was created by {name} | Researcher') # Adds activity to Hub Activity HubActivity.objects.create( action_user_id=request.user.id, action_type="Project Created", project_id=data.id, - action_account_type = 'researcher' + action_account_type='researcher' ) # Add project to researcher projects @@ -407,13 +668,18 @@ def create_project(request, researcher, source_proj_uuid=None, related=None): creator.researcher = researcher creator.save() + # Add selected contributors to the ProjectContributors object + add_to_contributors(request, researcher, data) + # Create notices for project notices_selected = request.POST.getlist('checkbox-notice') translations_selected = request.POST.getlist('checkbox-translation') - crud_notices(request, notices_selected, translations_selected, researcher, data, None, False) - - # Add selected contributors to the ProjectContributors object - add_to_contributors(request, researcher, data) + crud_notices( + request, + notices_selected, + translations_selected, + researcher, data, None, False + ) # Project person formset instances = formset.save(commit=False) @@ -425,8 +691,16 @@ def create_project(request, researcher, source_proj_uuid=None, related=None): send_project_person_email(request, instance.email, data.unique_id, researcher) # Send notification - title = 'Your project has been created, remember to notify a community of your project.' - ActionNotification.objects.create(title=title, sender=request.user, notification_type='Projects', researcher=researcher, reference_id=data.unique_id) + title = ( + 'Your project has been created, remember to notify a community of your project.' + ) + ActionNotification.objects.create( + title=title, + sender=request.user, + notification_type='Projects', + researcher=researcher, + reference_id=data.unique_id + ) return redirect('researcher-projects', researcher.id) else: @@ -445,7 +719,7 @@ def create_project(request, researcher, source_proj_uuid=None, related=None): @login_required(login_url='login') -@is_researcher('researcher_id') +@get_researcher(pk_arg_name='pk') def edit_project(request, researcher, project_uuid): project = Project.objects.get(unique_id=project_uuid) form = EditProjectForm(request.POST or None, instance=project) @@ -474,14 +748,25 @@ def edit_project(request, researcher, project_uuid): data.save() editor_name = get_users_name(request.user) - ProjectActivity.objects.create(project=data, activity=f'Edits to Project were made by {editor_name}') - communities = ProjectStatus.objects.filter( Q(status='pending') | Q(status__isnull=True),project=data, seen=True).select_related('community').order_by('community').distinct('community').values_list('community', flat=True) + ProjectActivity.objects.create( + project=data, + activity=f'Edits to Project were made by {editor_name}' + ) + + communities = ProjectStatus.objects.filter( + Q(status='pending') | Q(status__isnull=True), project=data, seen=True + ).select_related( + 'community' + ).order_by( + 'community' + ).distinct('community').values_list('community', flat=True) + # Adds activity to Hub Activity HubActivity.objects.create( action_user_id=request.user.id, action_type="Project Edited", project_id=data.id, - action_account_type = 'researcher' + action_account_type='researcher' ) instances = formset.save(commit=False) @@ -500,7 +785,15 @@ def edit_project(request, researcher, project_uuid): # Which notices were selected to change notices_selected = request.POST.getlist('checkbox-notice') translations_selected = request.POST.getlist('checkbox-translation') - has_changes = crud_notices(request, notices_selected, translations_selected, researcher, data, notices, has_changes) + has_changes = crud_notices( + request, + notices_selected, + translations_selected, + researcher, + data, + notices, + has_changes + ) if has_changes: send_action_notification_project_status(request, project, communities) @@ -517,8 +810,7 @@ def edit_project(request, researcher, project_uuid): 'user_can_view': True, 'urls': project.urls, 'notice_translations': notice_translations, - 'boundary_reset_url': reverse('reset-project-boundary', kwargs={'pk': project.id}), - 'boundary_preview_url': reverse('project-boundary-view', kwargs={'project_id': project.id}), + } return render(request, 'researchers/edit-project.html', context) @@ -526,44 +818,77 @@ def edit_project(request, researcher, project_uuid): def project_actions(request, pk, project_uuid): try: project = Project.objects.prefetch_related( - 'bc_labels', - 'tk_labels', - 'bc_labels__community', - 'tk_labels__community', - 'bc_labels__bclabel_translation', - 'tk_labels__tklabel_translation', - ).get(unique_id=project_uuid) + 'bc_labels', + 'tk_labels', + 'bc_labels__community', + 'tk_labels__community', + 'bc_labels__bclabel_translation', + 'tk_labels__tklabel_translation', + ).get(unique_id=project_uuid) if request.user.is_authenticated: researcher = Researcher.objects.get(id=pk) + subscription = Subscription.objects.filter(researcher=pk).first() user_can_view = checkif_user_researcher(researcher, request.user) if not user_can_view or not project.can_user_access(request.user): return redirect('view-project', project.unique_id) else: - notices = Notice.objects.filter(project=project, archived=False) + notices = Notice.objects.filter(project=project, archived=False).exclude( + notice_type='open_to_collaborate' + ) creator = ProjectCreator.objects.get(project=project) - statuses = ProjectStatus.objects.select_related('community').filter(project=project) - comments = ProjectComment.objects.select_related('sender').filter(project=project) + statuses = ProjectStatus.objects.select_related('community').filter( + project=project + ) + comments = ProjectComment.objects.select_related('sender').filter( + project=project + ) entities_notified = EntitiesNotified.objects.get(project=project) activities = ProjectActivity.objects.filter(project=project).order_by('-date') - sub_projects = Project.objects.filter(source_project_uuid=project.unique_id).values_list('unique_id', 'title') + sub_projects = Project.objects.filter( + source_project_uuid=project.unique_id + ).values_list('unique_id', 'title') name = get_users_name(request.user) label_groups = return_project_labels_by_community(project) - can_download = False if dev_prod_or_local(request.get_host()) == 'SANDBOX' else True - - # for related projects list - project_ids = list(set(researcher.researcher_created_project.all().values_list('project__unique_id', flat=True) - .union(researcher.researchers_notified.all().values_list('project__unique_id', flat=True)) - .union(researcher.contributing_researchers.all().values_list('project__unique_id', flat=True)))) - project_ids_to_exclude_list = list(project.related_projects.all().values_list('unique_id', flat=True)) #projects that are currently related + can_download = ( + False if dev_prod_or_local(request.get_host()) == 'SANDBOX' else True + ) + + if not researcher.is_subscribed: + can_download = False + + # for related projects list + project_ids = list( + set(researcher.researcher_created_project.all().values_list( + 'project__unique_id', flat=True + ) + .union(researcher.researchers_notified.all().values_list( + 'project__unique_id', flat=True + )) + .union(researcher.contributing_researchers.all().values_list( + 'project__unique_id', flat=True + )))) + project_ids_to_exclude_list = list(project.related_projects.all().values_list( + 'unique_id', flat=True + )) # projects that are currently related # exclude projects that are already related project_ids = list(set(project_ids).difference(project_ids_to_exclude_list)) - projects_to_link = Project.objects.filter(unique_id__in=project_ids).exclude(unique_id=project.unique_id).order_by('-date_added').values_list('unique_id', 'title') + projects_to_link = Project.objects.filter(unique_id__in=project_ids).exclude( + unique_id=project.unique_id).order_by('-date_added').values_list( + 'unique_id', + 'title' + ) project_archived = False - if ProjectArchived.objects.filter(project_uuid=project.unique_id, researcher_id=researcher.id).exists(): - x = ProjectArchived.objects.get(project_uuid=project.unique_id, researcher_id=researcher.id) + if ProjectArchived.objects.filter( + project_uuid=project.unique_id, + researcher_id=researcher.id + ).exists(): + x = ProjectArchived.objects.get( + project_uuid=project.unique_id, + researcher_id=researcher.id + ) project_archived = x.archived form = ProjectCommentForm(request.POST or None) @@ -574,8 +899,10 @@ def project_actions(request, pk, project_uuid): if creator.community: communities_list.append(creator.community.id) - communities_ids = list(set(communities_list)) # remove duplicate ids - communities = Community.approved.exclude(id__in=communities_ids).order_by('community_name') + communities_ids = list(set(communities_list)) # remove duplicate ids + communities = Community.approved.exclude(id__in=communities_ids).order_by( + 'community_name' + ) if request.method == 'POST': if request.POST.get('message'): @@ -586,26 +913,38 @@ def project_actions(request, pk, project_uuid): data.sender_affiliation = 'Researcher' data.save() send_action_notification_to_project_contribs(project) - return redirect('researcher-project-actions', researcher.id, project.unique_id) + return redirect( + 'researcher-project-actions', + researcher.id, + project.unique_id + ) - elif 'notify_btn' in request.POST: + elif 'notify_btn' in request.POST: # Set private project to contributor view if project.project_privacy == 'Private': project.project_privacy = 'Contributor' project.save() communities_selected = request.POST.getlist('selected_communities') + notification_count = subscription.notification_count + if notification_count == -1: + count = len(communities_selected) + else: + count = min(notification_count, len(communities_selected)) researcher_name = get_users_name(researcher.user) - title = f'{researcher_name} has notified you of a Project.' + title = f'{researcher_name} has notified you of a Project.' - for community_id in communities_selected: + for community_id in communities_selected[:count]: # Add communities that were notified to entities_notified instance community = Community.objects.get(id=community_id) entities_notified.communities.add(community) - + # Add activity - ProjectActivity.objects.create(project=project, activity=f'{community.community_name} was notified by {name}') + ProjectActivity.objects.create( + project=project, + activity=f'{community.community_name} was notified by {name}' + ) # Adds activity to Hub Activity HubActivity.objects.create( @@ -617,14 +956,30 @@ def project_actions(request, pk, project_uuid): ) # Create project status and notification - ProjectStatus.objects.create(project=project, community=community, seen=False) # Creates a project status for each community - ActionNotification.objects.create(community=community, notification_type='Projects', reference_id=str(project.unique_id), sender=request.user, title=title) + ProjectStatus.objects.create( + project=project, + community=community, + seen=False + ) # Creates a project status for each community + ActionNotification.objects.create( + community=community, + notification_type='Projects', + reference_id=str(project.unique_id), + sender=request.user, + title=title + ) entities_notified.save() - # Create email + # Create email send_email_notice_placed(request, project, community, researcher) - - return redirect('researcher-project-actions', researcher.id, project.unique_id) + if subscription.notification_count > 0: + subscription.notification_count -= notification_count + subscription.save() + return redirect( + 'researcher-project-actions', + researcher.id, + project.unique_id + ) elif 'link_projects_btn' in request.POST: selected_projects = request.POST.getlist('projects_to_link') @@ -635,21 +990,45 @@ def project_actions(request, pk, project_uuid): project_to_add.related_projects.add(project) project_to_add.save() - activities.append(ProjectActivity(project=project, activity=f'Project "{project_to_add.title}" was connected to Project by {name}')) - activities.append(ProjectActivity(project=project_to_add, activity=f'Project "{project.title}" was connected to Project by {name}')) - + activities.append(ProjectActivity( + project=project, + activity=( + f'Project "{project_to_add.title}" was connected to Project' + f' by {name}' + ) + )) + activities.append(ProjectActivity( + project=project_to_add, + activity=( + f'Project "{project.title}" was connected to Project' + f' by {name}' + ) + )) + ProjectActivity.objects.bulk_create(activities) project.save() - return redirect('researcher-project-actions', researcher.id, project.unique_id) + return redirect( + 'researcher-project-actions', + researcher.id, + project.unique_id + ) elif 'delete_project' in request.POST: - return redirect('researcher-delete-project', researcher.id, project.unique_id) - + return redirect( + 'researcher-delete-project', + researcher.id, + project.unique_id + ) + elif 'remove_contributor' in request.POST: contribs = ProjectContributors.objects.get(project=project) contribs.researchers.remove(researcher) contribs.save() - return redirect('researcher-project-actions', researcher.id, project.unique_id) + return redirect( + 'researcher-project-actions', + researcher.id, + project.unique_id + ) context = { 'user_can_view': user_can_view, @@ -667,19 +1046,31 @@ def project_actions(request, pk, project_uuid): 'projects_to_link': projects_to_link, 'label_groups': label_groups, 'can_download': can_download, + 'subscription': subscription, } return render(request, 'researchers/project-actions.html', context) else: return redirect('view-project', project.unique_id) - except: + except Project.DoesNotExist: raise Http404() + @login_required(login_url='login') def archive_project(request, researcher_id, project_uuid): - if not ProjectArchived.objects.filter(researcher_id=researcher_id, project_uuid=project_uuid).exists(): - ProjectArchived.objects.create(researcher_id=researcher_id, project_uuid=project_uuid, archived=True) + if not ProjectArchived.objects.filter( + researcher_id=researcher_id, + project_uuid=project_uuid + ).exists(): + ProjectArchived.objects.create( + researcher_id=researcher_id, + project_uuid=project_uuid, + archived=True + ) else: - archived_project = ProjectArchived.objects.get(researcher_id=researcher_id, project_uuid=project_uuid) + archived_project = ProjectArchived.objects.get( + researcher_id=researcher_id, + project_uuid=project_uuid + ) if archived_project.archived: archived_project.archived = False else: @@ -689,16 +1080,18 @@ def archive_project(request, researcher_id, project_uuid): @login_required(login_url='login') -def delete_project(request, researcher_id, project_uuid): - researcher = Researcher.objects.get(id=researcher_id) +def delete_project(request, pk, project_uuid): project = Project.objects.get(unique_id=project_uuid) + subscription = Subscription.objects.get(researcher=pk) - if ActionNotification.objects.filter(reference_id=project.unique_id).exists(): - for notification in ActionNotification.objects.filter(reference_id=project.unique_id): - notification.delete() - + delete_action_notification(project.unique_id) project.delete() - return redirect('researcher-projects', researcher.id) + + if subscription.project_count >= 0: + subscription.project_count += 1 + subscription.save() + return redirect('researcher-projects', pk) + @login_required(login_url='login') def unlink_project(request, pk, target_proj_uuid, proj_to_remove_uuid): @@ -710,26 +1103,60 @@ def unlink_project(request, pk, target_proj_uuid, proj_to_remove_uuid): target_project.save() project_to_remove.save() name = get_users_name(request.user) - ProjectActivity.objects.create(project=project_to_remove, activity=f'Connection was removed between Project "{project_to_remove}" and Project "{target_project}" by {name}') - ProjectActivity.objects.create(project=target_project, activity=f'Connection was removed between Project "{target_project}" and Project "{project_to_remove}" by {name}') + ProjectActivity.objects.create( + project=project_to_remove, + activity=( + f'Connection was removed between Project "{project_to_remove}"' + f' and Project "{target_project}" by {name}' + ) + ) + ProjectActivity.objects.create( + project=target_project, + activity=( + f'Connection was removed between Project "{target_project}"' + f' and Project "{project_to_remove}" by {name}' + ) + ) return redirect('researcher-project-actions', researcher.id, target_project.unique_id) - + @login_required(login_url='login') -@is_researcher() +@get_researcher(pk_arg_name='pk') def connections(request, researcher): - institution_ids = researcher.contributing_researchers.exclude(institutions__id=None).values_list('institutions__id', flat=True) - institutions = Institution.objects.select_related('institution_creator').prefetch_related('admins', 'editors', 'viewers').filter(id__in=institution_ids) - - community_ids = researcher.contributing_researchers.exclude(communities__id=None).values_list('communities__id', flat=True) - communities = Community.objects.select_related('community_creator').filter(id__in=community_ids) - - project_ids = researcher.contributing_researchers.values_list('project__unique_id', flat=True) - contributors = ProjectContributors.objects.filter(project__unique_id__in=project_ids) - - researchers = [] - for c in contributors: - researchers = c.researchers.select_related('user').exclude(id=researcher.id) + researchers = Researcher.objects.none() + + # Institution contributors + institution_ids = researcher.contributing_researchers.exclude( + institutions__id=None + ).values_list('institutions__id', flat=True) + institutions = ( + Institution.objects.select_related('institution_creator') + .prefetch_related('admins', 'editors', 'viewers') + .filter(id__in=institution_ids) + ) + + # Community contributors + community_ids = researcher.contributing_researchers.exclude( + communities__id=None + ).values_list('communities__id', flat=True) + communities = ( + Community.objects.select_related('community_creator') + .prefetch_related("admins", "editors", "viewers") + .filter(id__in=community_ids) + ) + + # Researcher contributors + project_ids = researcher.contributing_researchers.values_list( + "project__unique_id", flat=True + ) + contributors = ProjectContributors.objects.filter( + project__unique_id__in=project_ids + ).values_list("researchers__id", flat=True) + researchers = ( + Researcher.objects.select_related("user") + .filter(id__in=contributors) + .exclude(id=researcher.id) + ) context = { 'researcher': researcher, @@ -740,7 +1167,130 @@ def connections(request, researcher): } return render(request, 'researchers/connections.html', context) - + +@login_required(login_url="login") +@get_researcher(pk_arg_name='pk') +def connect_service_provider(request, researcher): + try: + if request.method == "GET": + service_providers = get_certified_service_providers(request) + connected_service_providers_ids = ServiceProviderConnections.objects.filter( + researchers=researcher + ).values_list('service_provider', flat=True) + connected_service_providers = service_providers.filter( + id__in=connected_service_providers_ids + ) + other_service_providers = service_providers.exclude( + id__in=connected_service_providers_ids + ) + + elif request.method == "POST": + if "connectServiceProvider" in request.POST: + if researcher.is_subscribed: + service_provider_id = request.POST.get('connectServiceProvider') + connection_reference_id = f"{service_provider_id}:{researcher.id}_r" + + if ServiceProviderConnections.objects.filter( + service_provider=service_provider_id).exists(): + # Connect researcher to existing Service Provider connection + sp_connection = ServiceProviderConnections.objects.get( + service_provider=service_provider_id + ) + sp_connection.researchers.add(researcher) + sp_connection.save() + else: + # Create new Service Provider Connection and add researcher + service_provider = ServiceProvider.objects.get(id=service_provider_id) + sp_connection = ServiceProviderConnections.objects.create( + service_provider=service_provider + ) + sp_connection.researchers.add(researcher) + sp_connection.save() + + # Delete instances of disconnect Notifications + delete_action_notification(connection_reference_id) + + # Send notification of connection to Service Provider + target_org = sp_connection.service_provider + name = get_users_name(request.user) + title = f"{name} has connected to {target_org.name}" + send_simple_action_notification( + None, target_org, title, "Connections", connection_reference_id + ) + else: + messages.add_message( + request, messages.ERROR, + 'Your account must be subscribed to connect to Service Providers.' + ) + + elif "disconnectServiceProvider" in request.POST: + service_provider_id = request.POST.get('disconnectServiceProvider') + connection_reference_id = f"{service_provider_id}:{researcher.id}_r" + + sp_connection = ServiceProviderConnections.objects.get( + service_provider=service_provider_id + ) + sp_connection.researchers.remove(researcher) + sp_connection.save() + + # Delete instances of the connection notification + delete_action_notification(connection_reference_id) + + # Send notification of disconneciton to Service Provider + target_org = sp_connection.service_provider + name = get_users_name(request.user) + title = f"{name} has been disconnected from {target_org.name}" + send_simple_action_notification( + None, target_org, title, "Connections", connection_reference_id + ) + + return redirect("researcher-connect-service-provider", researcher.id) + + context = { + 'researcher': researcher, + 'user_can_view': True, + 'other_service_providers': other_service_providers, + 'connected_service_providers': connected_service_providers, + } + return render(request, 'account_settings_pages/_connect-service-provider.html', context) + except ServiceProvider.DoesNotExist: + raise Http404() + + +@login_required(login_url="login") +@get_researcher(pk_arg_name='pk') +def account_preferences(request, researcher): + try: + if request.method == "POST": + + # Set Show/Hide account in Service Provider connections + if request.POST.get('show_sp_connection') == 'on': + researcher.show_sp_connection = True + + elif request.POST.get('show_sp_connection') is None: + researcher.show_sp_connection = False + + # Set project privacy settings for Service Provider connections + researcher.sp_privacy = request.POST.get('sp_privacy') + + researcher.save() + + messages.add_message( + request, messages.SUCCESS, 'Your preferences have been updated!' + ) + + return redirect("preferences-researcher", researcher.id) + + context = { + 'researcher': researcher, + 'user_can_view': True, + } + return render(request, 'account_settings_pages/_preferences.html', context) + + except Exception as e: + raise Http404(str(e)) + + @force_maintenance_mode_off def embed_otc_notice(request, pk): layout = request.GET.get('lt') @@ -749,16 +1299,144 @@ def embed_otc_notice(request, pk): researcher = Researcher.objects.get(id=pk) otc_notices = OpenToCollaborateNoticeURL.objects.filter(researcher=researcher) - + context = { - 'layout' : layout, - 'lang' : lang, - 'align' : align, - 'otc_notices' : otc_notices, - 'researcher' : researcher, + 'layout': layout, + 'lang': lang, + 'align': align, + 'otc_notices': otc_notices, + 'researcher': researcher, } response = render(request, 'partials/_embed.html', context) response['Content-Security-Policy'] = 'frame-ancestors https://*' return response + + +# Create API Key +@login_required(login_url="login") +@get_researcher(pk_arg_name='pk') +@transaction.atomic +def api_keys(request, researcher, related=None): + remaining_api_key_count = 0 + + try: + if researcher.is_subscribed: + subscription = Subscription.objects.get(researcher=researcher) + remaining_api_key_count = subscription.api_key_count + + if request.method == 'GET': + form = APIKeyGeneratorForm(request.GET or None) + account_keys = AccountAPIKey.objects.filter(researcher=researcher).exclude( + Q(expiry_date__lt=timezone.now()) | Q(revoked=True) + ).values_list("prefix", "name", "encrypted_key") + + elif request.method == "POST": + if "generate_api_key" in request.POST: + if researcher.is_subscribed and subscription.api_key_count == 0: + messages.add_message( + request, + messages.ERROR, + 'Your account has reached its API Key limit. ' + 'Please upgrade your subscription plan to create more API Keys.' + ) + return redirect("researcher-api-key", researcher.id) + form = APIKeyGeneratorForm(request.POST) + + if researcher.is_subscribed: + if form.is_valid(): + data = form.save(commit=False) + api_key, key = AccountAPIKey.objects.create_key( + name=data.name, + researcher_id=researcher.id + ) + prefix = key.split(".")[0] + encrypted_key = urlsafe_base64_encode(force_bytes(key)) + AccountAPIKey.objects.filter(prefix=prefix).update( + encrypted_key=encrypted_key + ) + + if subscription.api_key_count > 0: + subscription.api_key_count -= 1 + subscription.save() + else: + messages.add_message( + request, + messages.ERROR, + 'Please enter a valid API Key name.' + ) + return redirect("researcher-api-key", researcher.id) + + else: + messages.add_message( + request, + messages.ERROR, + 'Your account is not subscribed. ' + 'You must have an active subscription to create more API Keys.' + ) + return redirect("researcher-api-key", researcher.id) + + return redirect("researcher-api-key", researcher.id) + + elif "delete_api_key" in request.POST: + prefix = request.POST['delete_api_key'] + api_key = AccountAPIKey.objects.filter(prefix=prefix) + api_key.delete() + + if researcher.is_subscribed and subscription.api_key_count >= 0: + subscription.api_key_count += 1 + subscription.save() + + return redirect("researcher-api-key", researcher.id) + + context = { + "researcher": researcher, + "form": form, + "account_keys": account_keys, + "remaining_api_key_count": remaining_api_key_count + } + return render(request, 'account_settings_pages/_api-keys.html', context) + except Exception as e: + raise Http404(str(e)) + +@login_required(login_url="login") +def create_researcher_subscription(request, pk): + researcher = Researcher.objects.get(id=pk) + env = dev_prod_or_local(request.get_host()) + initial_data = { + "first_name": request.user._wrapped.first_name, + "last_name": request.user._wrapped.last_name, + "email": request.user._wrapped.email, + "organization_name": researcher.primary_institution, + } + subscription_form = SubscriptionForm(initial=initial_data) + subscription_form.fields['organization_name'].widget.attrs.update({"class": "w-100 readonly-input"}) + + if request.method == "POST": + if validate_recaptcha(request): + mutable_post_data = request.POST.copy() + subscription_data = { + "first_name": request.user._wrapped.first_name, + "last_name": request.user._wrapped.last_name, + "email": request.user._wrapped.email, + "inquiry_type": "Subscription", + "account_type": "researcher_account", + "organization_name": get_users_name(request.user), + } + mutable_post_data.update(subscription_data) + subscription_form = SubscriptionForm(mutable_post_data) + if subscription_form.is_valid() and env != 'SANDBOX': + handle_confirmation_and_subscription(request, subscription_form, researcher, env) + return redirect('dashboard') + else: + messages.add_message( + request, messages.ERROR, "Something went wrong. Please Try again later.", + ) + return redirect('dashboard') + return render( + request, "account_settings_pages/_subscription-form.html", { + "subscription_form": subscription_form, + 'researcher': researcher + } + ) \ No newline at end of file diff --git a/serviceproviders/__init__.py b/serviceproviders/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/serviceproviders/apps.py b/serviceproviders/apps.py new file mode 100644 index 000000000..590b53875 --- /dev/null +++ b/serviceproviders/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class ServiceProvidersConfig(AppConfig): + name = 'serviceproviders' \ No newline at end of file diff --git a/serviceproviders/decorators.py b/serviceproviders/decorators.py new file mode 100644 index 000000000..3b944789a --- /dev/null +++ b/serviceproviders/decorators.py @@ -0,0 +1,17 @@ +from functools import wraps +from django.shortcuts import redirect +from helpers.utils import check_member_role +from .utils import get_service_provider + + +def member_required(roles=[]): + def decorator(view_func): + @wraps(view_func) + def _wrapped_view(request, *args, **kwargs): + service_provider = get_service_provider(kwargs.get('pk')) + member_role = check_member_role(request.user, service_provider) + if member_role not in roles: + return redirect('restricted') + return view_func(request, *args, **kwargs) + return _wrapped_view + return decorator diff --git a/serviceproviders/forms.py b/serviceproviders/forms.py new file mode 100644 index 000000000..df73067a3 --- /dev/null +++ b/serviceproviders/forms.py @@ -0,0 +1,50 @@ +from django import forms +from .models import ServiceProvider +from django.utils.translation import gettext_lazy as _ + + +class CreateServiceProviderForm(forms.ModelForm): + + class Meta: + model = ServiceProvider + fields = ['name', 'description', 'website', 'contact_name', 'contact_email',] + error_messages = { + 'name': {'unique': _("This service provider is already on the Hub."), }, + } + widgets = { + 'name': forms.TextInput( + attrs={'name': 'name', 'class': 'w-100', 'autocomplete': 'off', 'required': True} + ), + 'description': forms.Textarea( + attrs={'class': 'w-100', 'rows': 2, 'required': True} + ), + 'website': forms.TextInput(attrs={'class': 'w-100'}), + 'contact_name': forms.TextInput( + attrs={ + 'class': 'w-100', 'id': 'serviceProviderContactNameField', 'required': True + } + ), + 'contact_email': forms.EmailInput( + attrs={ + 'class': 'w-100', 'id': 'serviceProviderContactEmailField', 'required': True + } + ), + } + + +class UpdateServiceProviderForm(forms.ModelForm): + + class Meta: + model = ServiceProvider + fields = ['image', 'description', 'website', 'documentation'] + widgets = { + 'description': forms.Textarea(attrs={'class': 'w-100', 'rows': 3}), + 'image': forms.ClearableFileInput( + attrs={ + 'class': 'w-100 hide', 'id': 'serviceProviderImgUploadBtn', + 'onchange': 'showFile()', + } + ), + 'website': forms.TextInput(attrs={'class': 'w-100'}), + 'documentation': forms.TextInput(attrs={'class': 'w-100'}), + } diff --git a/serviceproviders/migrations/0001_initial.py b/serviceproviders/migrations/0001_initial.py new file mode 100644 index 000000000..708cab831 --- /dev/null +++ b/serviceproviders/migrations/0001_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 5.0.6 on 2024-06-21 15:01 + +import django.core.validators +import django.db.models.deletion +import serviceproviders.models +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ServiceProvider', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100, null=True, unique=True)), + ('contact_name', models.CharField(blank=True, max_length=80, null=True)), + ('contact_email', models.EmailField(blank=True, max_length=254, null=True)), + ('image', models.ImageField(blank=True, null=True, upload_to=serviceproviders.models.service_provider_img_path)), + ('description', models.TextField(blank=True, null=True, validators=[django.core.validators.MaxLengthValidator(200)])), + ('website', models.URLField(blank=True, max_length=150, null=True)), + ('created', models.DateTimeField(auto_now_add=True, null=True)), + ('is_certified', models.BooleanField(default=False)), + ('account_creator', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Service Provider', + 'verbose_name_plural': 'Service Providers', + 'indexes': [models.Index(fields=['id', 'account_creator', 'image'], name='serviceprov_id_cfe6c8_idx')], + }, + ), + ] diff --git a/serviceproviders/migrations/0002_serviceprovider_documentation_and_more.py b/serviceproviders/migrations/0002_serviceprovider_documentation_and_more.py new file mode 100644 index 000000000..1a91a42ca --- /dev/null +++ b/serviceproviders/migrations/0002_serviceprovider_documentation_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.0.6 on 2024-07-08 20:55 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('serviceproviders', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='serviceprovider', + name='documentation', + field=models.URLField(blank=True, max_length=150, null=True), + ), + migrations.AddField( + model_name='serviceprovider', + name='editors', + field=models.ManyToManyField(blank=True, related_name='service_provider_editors', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/serviceproviders/migrations/0003_serviceprovider_show_connections.py b/serviceproviders/migrations/0003_serviceprovider_show_connections.py new file mode 100644 index 000000000..c14db45ae --- /dev/null +++ b/serviceproviders/migrations/0003_serviceprovider_show_connections.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.11 on 2024-07-24 16:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('serviceproviders', '0002_serviceprovider_documentation_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='serviceprovider', + name='show_connections', + field=models.BooleanField(default=False), + ), + ] diff --git a/serviceproviders/migrations/0004_alter_serviceprovider_show_connections.py b/serviceproviders/migrations/0004_alter_serviceprovider_show_connections.py new file mode 100644 index 000000000..09bb1d9f5 --- /dev/null +++ b/serviceproviders/migrations/0004_alter_serviceprovider_show_connections.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-08-13 15:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('serviceproviders', '0003_serviceprovider_show_connections'), + ] + + operations = [ + migrations.AlterField( + model_name='serviceprovider', + name='show_connections', + field=models.BooleanField(default=True), + ), + ] diff --git a/serviceproviders/migrations/0005_serviceprovider_certified_by.py b/serviceproviders/migrations/0005_serviceprovider_certified_by.py new file mode 100644 index 000000000..c40cd6e65 --- /dev/null +++ b/serviceproviders/migrations/0005_serviceprovider_certified_by.py @@ -0,0 +1,21 @@ +# Generated by Django 5.0.6 on 2024-09-11 21:29 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('serviceproviders', '0004_alter_serviceprovider_show_connections'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='serviceprovider', + name='certified_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='service_provider_approver', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/serviceproviders/migrations/0006_serviceprovider_certification_type.py b/serviceproviders/migrations/0006_serviceprovider_certification_type.py new file mode 100644 index 000000000..100fc8e69 --- /dev/null +++ b/serviceproviders/migrations/0006_serviceprovider_certification_type.py @@ -0,0 +1,18 @@ +# Generated by Django 5.0.6 on 2024-09-17 14:51 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('serviceproviders', '0005_serviceprovider_certified_by'), + ] + + operations = [ + migrations.AddField( + model_name='serviceprovider', + name='certification_type', + field=models.CharField(blank=True, choices=[('manual', 'Manual'), ('oauth', 'OAuth')], max_length=20, null=True), + ), + ] diff --git a/serviceproviders/migrations/__init__.py b/serviceproviders/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/serviceproviders/models.py b/serviceproviders/models.py new file mode 100644 index 000000000..82993540a --- /dev/null +++ b/serviceproviders/models.py @@ -0,0 +1,76 @@ +from django.db import models +from django.contrib.auth.models import User +from django.core.validators import MaxLengthValidator +import uuid +import os + + +class CertifiedManager(models.Manager): + def get_queryset(self): + return super(CertifiedManager, self).get_queryset().filter(is_certified=True) + + +def service_provider_img_path(self, filename): + ext = filename.split('.')[-1] + filename = "%s.%s" % (str(uuid.uuid4()), ext) + return os.path.join('users/service-provider-images', filename) + + +class ServiceProvider(models.Model): + CERTIFICATION_CHOICES = ( + ('manual', 'Manual'), + ('oauth', 'OAuth'), + ) + + account_creator = models.ForeignKey( + User, on_delete=models.CASCADE, null=True + ) + name = models.CharField(max_length=100, null=True, unique=True) + contact_name = models.CharField(max_length=80, null=True, blank=True) + contact_email = models.EmailField(max_length=254, null=True, blank=True) + image = models.ImageField( + upload_to=service_provider_img_path, blank=True, null=True + ) + description = models.TextField( + null=True, blank=True, validators=[MaxLengthValidator(200)] + ) + website = models.URLField(max_length=150, blank=True, null=True) + documentation = models.URLField(max_length=150, blank=True, null=True) + editors = models.ManyToManyField( + User, blank=True, related_name="service_provider_editors" + ) + created = models.DateTimeField(auto_now_add=True, null=True) + is_certified = models.BooleanField(default=False) + certification_type = models.CharField(max_length=20, choices=CERTIFICATION_CHOICES, blank=True, null=True) + certified_by = models.ForeignKey( + User, null=True, blank=True, on_delete=models.SET_NULL, + related_name="service_provider_approver" + ) + show_connections = models.BooleanField(default=True) + + # Managers + objects = models.Manager() + certified = CertifiedManager() + + def __str__(self): + return str(self.name) + + def get_member_count(self): + admins = 1 + editors = self.editors.count() + total_members = admins + editors + return total_members + + def get_editors(self): + return self.editors.all() + + def is_user_in_service_provider(self, user): + if user in self.editors.all() or user == self.account_creator: + return True + else: + return False + + class Meta: + indexes = [models.Index(fields=['id', 'account_creator', 'image'])] + verbose_name = 'Service Provider' + verbose_name_plural = 'Service Providers' diff --git a/serviceproviders/tests.py b/serviceproviders/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/serviceproviders/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/serviceproviders/urls.py b/serviceproviders/urls.py new file mode 100644 index 000000000..cd6defdbd --- /dev/null +++ b/serviceproviders/urls.py @@ -0,0 +1,42 @@ +from django.urls import path +from . import views + +urlpatterns = [ + # Creating Accounts + path('preparation-step/', views.preparation_step, name="prep-service-provider"), + path( + 'create-service-provider/', views.create_service_provider, name="create-service-provider" + ), + + # Settings + path('update//', views.update_service_provider, name="update-service-provider"), + path( + 'preferences//', views.account_preferences, name="preferences-service-provider" + ), + path('api-key//', views.api_keys, name="service-provider-api-key"), + + # Public view + path('view//', views.public_service_provider_view, name="public-service-provider"), + + # Notices + path('notices//', views.service_provider_notices, name="service-provider-notices"), + path( + 'notices/otc/delete///', views.delete_otc_notice, + name="service-provider-delete-otc" + ), + path('embed//', views.embed_otc_notice, name="embed-notice-service-provider"), + + # Members + path('members//', views.service_provider_members, name="service-provider-members"), + path( + 'members/invites//', views.service_provider_member_invites, + name="service-provider-member-intives" + ), + path( + 'members/remove//', views.service_provider_remove_member, + name="service-provider-remove-member" + ), + + # Connections + path('connections//', views.connections, name="service-provider-connections"), +] diff --git a/serviceproviders/utils.py b/serviceproviders/utils.py new file mode 100644 index 000000000..a8921b08c --- /dev/null +++ b/serviceproviders/utils.py @@ -0,0 +1,43 @@ +from django.shortcuts import redirect +from django.contrib import messages +from django.db import transaction +from helpers.models import HubActivity +from accounts.models import UserAffiliation +from .models import ServiceProvider +from helpers.utils import handle_confirmation_and_subscription + + +def handle_service_provider_creation(request, form, subscription_form, env): + try: + with transaction.atomic(): + data = form.save(commit=False) + data.account_creator = request.user + data.save() + if env != 'SANDBOX': + handle_confirmation_and_subscription( + request, subscription_form, data, env + ) + affiliation = UserAffiliation.objects.prefetch_related( + "service_providers").get(user=request.user) + affiliation.service_providers.add(data) + affiliation.save() + + HubActivity.objects.create( + action_user_id=request.user.id, + action_type="New Service Provider", + service_provider_id=data.id, + action_account_type="service_provider", + ) + except Exception: + messages.add_message( + request, + messages.ERROR, + "An unexpected error has occurred here." + " Please contact support@localcontexts.org.", + ) + return redirect('dashboard') + + +def get_service_provider(pk): + return ServiceProvider.objects.select_related( + 'account_creator').get(id=pk) diff --git a/serviceproviders/views.py b/serviceproviders/views.py new file mode 100644 index 000000000..3ecda7b15 --- /dev/null +++ b/serviceproviders/views.py @@ -0,0 +1,644 @@ +from datetime import timezone +from django.shortcuts import render, redirect +from django.contrib.auth.decorators import login_required +from django.contrib import messages +from django.http import Http404 +from itertools import chain +from django.db.models import Q +from .decorators import member_required +from maintenance_mode.decorators import force_maintenance_mode_off + +from localcontexts.utils import dev_prod_or_local +from helpers.utils import ( + InviteMember, validate_recaptcha, check_member_role, encrypt_api_key, + form_initiation, change_member_role +) +from notifications.utils import ( + UserNotification, send_account_member_invite, send_simple_action_notification, + delete_action_notification +) +from .utils import handle_service_provider_creation, get_service_provider + +from django.contrib.auth.models import User +from helpers.models import OpenToCollaborateNoticeURL +from api.models import AccountAPIKey +from accounts.models import UserAffiliation, ServiceProviderConnections +from institutions.models import Institution +from communities.models import Community +from researchers.models import Researcher +from .models import ServiceProvider + +from helpers.forms import OpenToCollaborateNoticeURLForm, HubActivity +from communities.forms import InviteMemberForm +from accounts.forms import ContactOrganizationForm, SignUpInvitationForm, SubscriptionForm +from api.forms import APIKeyGeneratorForm +from .forms import CreateServiceProviderForm, UpdateServiceProviderForm + +from helpers.emails import send_contact_email, send_member_invite_email + + +# ACCOUNT CREATION +@login_required(login_url="login") +def preparation_step(request): + if dev_prod_or_local(request.get_host()) == "SANDBOX": + return redirect("create-service-provider") + else: + service_provider = True + return render( + request, "accounts/preparation.html", {"service_provider": service_provider} + ) + + +@login_required(login_url="login") +def create_service_provider(request): + form = CreateServiceProviderForm() + user_form = form_initiation(request) + subscription_form = SubscriptionForm() + env = dev_prod_or_local(request.get_host()) + + if request.method == "POST": + form = CreateServiceProviderForm(request.POST) + if (form.is_valid() and user_form.is_valid() and validate_recaptcha(request)): + mutable_post_data = request.POST.copy() + subscription_data = { + "first_name": user_form.cleaned_data['first_name'], + "last_name": user_form.cleaned_data['last_name'], + "email": request.user._wrapped.email, + "inquiry_type": "Service Provider", + "account_type": "service_provider_account", + "organization_name": form.cleaned_data['name'], + } + + mutable_post_data.update(subscription_data) + subscription_form = SubscriptionForm(mutable_post_data) + + if subscription_form.is_valid(): + handle_service_provider_creation(request, form, subscription_form, env) + return redirect('dashboard') + else: + messages.add_message( + request, messages.ERROR, "Something went wrong. Please Try again later." + ) + return redirect('dashboard') + + context = { + "form": form, + "subscription_form": subscription_form, + "user_form": user_form, + } + + return render(request, "serviceproviders/create-service-provider.html", context) + + +# PUBLIC VIEW +def public_service_provider_view(request, pk): + try: + environment = dev_prod_or_local(request.get_host()) + service_provider = ServiceProvider.objects.get(id=pk) + + # Do notices exist + otc_notices = OpenToCollaborateNoticeURL.objects.filter( + service_provider=service_provider + ) + + # Do connections exist + sp_connections = ServiceProviderConnections.objects.filter( + service_provider=service_provider + ).exclude(institutions__id=None, communities__id=None, researchers__id=None) + + if sp_connections: + institution_ids = sp_connections.values_list('institutions__id', flat=True) + community_ids = sp_connections.values_list('communities__id', flat=True) + researcher_ids = sp_connections.values_list('researchers__id', flat=True) + + communities = Community.objects.filter( + id__in=community_ids + ).exclude(show_sp_connection=False) + researchers = Researcher.objects.filter( + id__in=researcher_ids + ).exclude(show_sp_connection=False) + institutions = Institution.objects.filter( + id__in=institution_ids + ).exclude(show_sp_connection=False) + else: + communities = None + researchers = None + institutions = None + + if request.user.is_authenticated: + user_service_providers = ( + UserAffiliation.objects.prefetch_related("service_providers") + .get(user=request.user) + .service_providers.all() + ) + form = ContactOrganizationForm(request.POST or None) + + if request.method == "POST": + if "contact_btn" in request.POST: + # contact service provider + if form.is_valid(): + from_name = form.cleaned_data["name"] + from_email = form.cleaned_data["email"] + message = form.cleaned_data["message"] + to_email = service_provider.account_creator.email + + send_contact_email( + request, to_email, from_name, from_email, message, service_provider, + ) + messages.add_message(request, messages.SUCCESS, "Message sent!") + return redirect("public-service-provider", service_provider.id) + else: + if not form.data["message"]: + messages.add_message( + request, messages.ERROR, "Unable to send an empty message.", + ) + return redirect("public-service-provider", service_provider.id) + + else: + messages.add_message(request, messages.ERROR, "Something went wrong.") + return redirect("public-service-provider", service_provider.id) + + else: + context = { + "service_provider": service_provider, + "otc_notices": otc_notices, + "env": environment, + "sp_connections": sp_connections, + "communities": communities, + "researchers": researchers, + "institutions": institutions, + } + return render(request, "public.html", context) + + context = { + "service_provider": service_provider, + "form": form, + "user_service_providers": user_service_providers, + "sp_connections": sp_connections, + "otc_notices": otc_notices, + "env": environment, + "communities": communities, + "researchers": researchers, + "institutions": institutions, + } + return render(request, "public.html", context) + except Exception: + raise Http404() + + +# NOTICES +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) +def service_provider_notices(request, pk): + service_provider = get_service_provider(pk) + member_role = check_member_role(request.user, service_provider) + urls = OpenToCollaborateNoticeURL.objects.filter( + service_provider=service_provider + ).values_list("url", "name", "id") + form = OpenToCollaborateNoticeURLForm(request.POST or None) + + if service_provider.is_certified: + not_approved_download_notice = None + not_approved_shared_notice = None + else: + not_approved_download_notice = "Your account needs to be certified to download this " \ + "Notice." + not_approved_shared_notice = "Your account needs to be certified to share this Notice." + + # sets permission to download OTC Notice + if dev_prod_or_local(request.get_host()) == "SANDBOX": + is_sandbox = True + otc_download_perm = 0 + download_notice_on_sandbox = "Download of Notices is not available on the sandbox site." + share_notice_on_sandbox = "Sharing of Notices is not available on the sandbox site." + else: + is_sandbox = False + otc_download_perm = 1 if service_provider.is_certified else 0 + download_notice_on_sandbox = None + share_notice_on_sandbox = None + + if request.method == "POST" and form.is_valid(): + data = form.save(commit=False) + data.service_provider = service_provider + data.save() + # Adds activity to Hub Activity + HubActivity.objects.create( + action_user_id=request.user.id, + action_type="Engagement Notice Added", + project_id=data.id, + action_account_type="service_provider", + service_provider_id=service_provider.id, + ) + return redirect("service-provider-notices", service_provider.id) + + context = { + "service_provider": service_provider, + "member_role": member_role, + "form": form, + "urls": urls, + "otc_download_perm": otc_download_perm, + 'not_approved_download_notice': not_approved_download_notice, + 'download_notice_on_sandbox': download_notice_on_sandbox, + 'not_approved_shared_notice': not_approved_shared_notice, + 'share_notice_on_sandbox': share_notice_on_sandbox, + "is_sandbox": is_sandbox, + } + return render(request, "serviceproviders/notices.html", context) + + +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) +def delete_otc_notice(request, pk, notice_id): + if OpenToCollaborateNoticeURL.objects.filter(id=notice_id).exists(): + otc = OpenToCollaborateNoticeURL.objects.get(id=notice_id) + otc.delete() + return redirect("service-provider-notices", pk) + + +@force_maintenance_mode_off +def embed_otc_notice(request, pk): + layout = request.GET.get("lt") + lang = request.GET.get("lang") + align = request.GET.get("align") + + service_provider = get_service_provider(pk) + otc_notices = OpenToCollaborateNoticeURL.objects.filter(service_provider=service_provider) + + context = { + "layout": layout, + "lang": lang, + "align": align, + "otc_notices": otc_notices, + "service_provider": service_provider, + } + + response = render(request, "partials/_embed.html", context) + response["Content-Security-Policy"] = "frame-ancestors https://*" + + return response + + +# CONNECTIONS +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) +def connections(request, pk): + try: + service_provider = get_service_provider(pk) + member_role = check_member_role(request.user, service_provider) + if request.method == "GET": + sp_connections = ServiceProviderConnections.objects.filter( + service_provider=service_provider + ) + + institution_ids = sp_connections.values_list('institutions__id', flat=True) + community_ids = sp_connections.values_list('communities__id', flat=True) + researcher_ids = sp_connections.values_list('researchers__id', flat=True) + + communities = Community.objects.filter(id__in=community_ids) + researchers = Researcher.objects.filter(id__in=researcher_ids) + institutions = Institution.objects.filter(id__in=institution_ids) + + elif request.method == "POST": + if "disconnectAccount" in request.POST: + connection_reference_id = f"{service_provider.id}:{request.POST.get('disconnectAccount')}" # noqa + account_id, account_type = request.POST.get('disconnectAccount').split('_') + sp_connection = ServiceProviderConnections.objects.get( + service_provider=service_provider + ) + + # Delete instances of the connection notification + delete_action_notification(connection_reference_id) + + if account_type == "i": + sp_connection.institutions.remove(account_id) + + # Send notification of disconnection to account + institution = Institution.objects.get(id=account_id) + title = f"{service_provider.name} (Service Provider) has removed your "\ + "connection" + send_simple_action_notification( + None, institution, title, "Activity", connection_reference_id + ) + + elif account_type == "c": + sp_connection.communities.remove(account_id) + + # Send notification of disconnection to account + community = Community.objects.get(id=account_id) + title = f"{service_provider.name} (Service Provider) has removed your "\ + "connection" + send_simple_action_notification( + None, community, title, "Activity", connection_reference_id + ) + + elif account_type == "r": + sp_connection.researchers.remove(account_id) + # name = get_users_name(request.user) + + # Send notification of disconnection to account + researcher = Researcher.objects.get(id=account_id) + title = f"{service_provider.name} (Service Provider) has removed your "\ + "connection" + send_simple_action_notification( + None, researcher, title, "Activity", connection_reference_id + ) + + sp_connection.save() + + return redirect("service-provider-connections", service_provider.id) + + context = { + "service_provider": service_provider, + "member_role": member_role, + "communities": communities, + "researchers": researchers, + "institutions": institutions, + } + return render(request, "serviceproviders/connections.html", context) + + except Exception: + raise Http404() + + +# MEMBERS +@login_required(login_url='login') +@member_required(roles=['admin', 'editor']) +def service_provider_members(request, pk): + service_provider = get_service_provider(pk) + member_role = check_member_role(request.user, service_provider) + + # Get list of users, NOT in this account, alphabetized by name + members = list(chain( + service_provider.editors.all().values_list('id', flat=True), + )) + + # include account creator + members.append(service_provider.account_creator.id) + users = User.objects.exclude(id__in=members).order_by('username') + + form = InviteMemberForm(request.POST or None, service_provider=service_provider) + + if request.method == "POST": + if 'change_member_role_btn' in request.POST: + current_role = request.POST.get('current_role') + new_role = request.POST.get('new_role') + user_id = request.POST.get('user_id') + member = User.objects.get(id=user_id) + change_member_role(service_provider, member, current_role, new_role) + return redirect('members', service_provider.id) + + elif 'send_invite_btn' in request.POST: + selected_user = User.objects.none() + if form.is_valid(): + data = form.save(commit=False) + + # Get target User + selected_username = request.POST.get('userList') + username_to_check = '' + + '''if username includes spaces means it has a + first and last name (last name,first name)''' + if ' ' in selected_username: + x = selected_username.split(' ') + username_to_check = x[0] + else: + username_to_check = selected_username + + if not username_to_check in users.values_list('username', flat=True): + message = "Invalid user selection. Please select user from the list." + messages.add_message(request, messages.INFO, message) + else: + selected_user = User.objects.get(username=username_to_check) + + # Check to see if an invite request aleady exists + invitation_exists = InviteMember.objects.filter( + receiver=selected_user, + service_provider=service_provider + ).exists() # Check to see if invitation already exists + + # If invitation request does not exist, save form + if not invitation_exists: + data.receiver = selected_user + data.sender = request.user + data.status = 'sent' + data.service_provider = service_provider + data.save() + + # Send action notification + send_account_member_invite(data) + + # Send email to target user + send_member_invite_email(request, data, service_provider) + messages.add_message( + request, messages.INFO, f'Invitation sent to {selected_user}!' + ) + return redirect('service-provider-members', service_provider.id) + else: + message = f"The user you are trying to add already has an invitation" \ + f"invitation pending to join {service_provider.name}." + messages.add_message(request, messages.INFO, message) + else: + messages.add_message(request, messages.INFO, 'Something went wrong.') + + context = { + 'service_provider': service_provider, + 'member_role': member_role, + 'form': form, + 'users': users, + 'invite_form': SignUpInvitationForm(), + 'env': dev_prod_or_local(request.get_host()), + } + return render(request, 'serviceproviders/members.html', context) + + +@login_required(login_url='login') +@member_required(roles=['admin', 'editor', 'viewer']) +def service_provider_member_invites(request, pk): + service_provider = get_service_provider(pk) + member_role = check_member_role(request.user, service_provider) + member_invites = InviteMember.objects.filter(service_provider=service_provider) + + context = { + 'member_role': member_role, + 'service_provider': service_provider, + 'member_invites': member_invites, + } + return render(request, 'serviceproviders/member-requests.html', context) + + +@login_required(login_url='login') +@member_required(roles=['admin']) +def service_provider_remove_member(request, pk, member_id): + service_provider = get_service_provider(pk) + member = User.objects.get(id=member_id) + # what role does member have + # remove from role + if member in service_provider.editors.all(): + service_provider.editors.remove(member) + + # remove account from userAffiliation instance + affiliation = UserAffiliation.objects.get(user=member) + affiliation.service_providers.remove(service_provider) + + title = f'You have been removed as a member from {service_provider.name}.' + UserNotification.objects.create( + from_user=request.user, + to_user=member, + title=title, + notification_type="Remove", + service_provider=service_provider + ) + + if '/manage/' in request.META.get('HTTP_REFERER'): + return redirect('manage-orgs') + else: + return redirect('service-provider-members', service_provider.id) + + +# ACCOUNT SETTINGS +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) +def update_service_provider(request, pk): + service_provider = get_service_provider(pk) + member_role = check_member_role(request.user, service_provider) + + if request.method == "POST": + update_form = UpdateServiceProviderForm( + request.POST, request.FILES, instance=service_provider + ) + + if "clear_image" in request.POST: + service_provider.image = None + service_provider.save() + return redirect("update-service-provider", service_provider.id) + else: + if update_form.is_valid(): + update_form.save() + messages.add_message(request, messages.SUCCESS, "Settings updated!") + return redirect("update-service-provider", service_provider.id) + else: + update_form = UpdateServiceProviderForm(instance=service_provider) + + context = { + "service_provider": service_provider, + "update_form": update_form, + "member_role": member_role, + } + return render( + request, 'account_settings_pages/_update-account.html', context + ) + + +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) +def account_preferences(request, pk): + try: + service_provider = get_service_provider(pk) + member_role = check_member_role(request.user, service_provider) + + if request.method == "POST": + + # Set Show/Hide account in Service Provider connections + if request.POST.get('show_connections') == 'on': + service_provider.show_connections = True + service_provider.save() + + elif request.POST.get('show_connections') is None: + service_provider.show_connections = False + service_provider.save() + + messages.add_message( + request, messages.SUCCESS, 'Your preferences have been updated!' + ) + + return redirect("preferences-service-provider", service_provider.id) + + context = { + 'member_role': member_role, + 'service_provider': service_provider, + } + return render(request, 'account_settings_pages/_preferences.html', context) + + except Exception: + raise Http404() + + +@login_required(login_url="login") +@member_required(roles=["admin", "editor"]) +def api_keys(request, pk): + service_provider = get_service_provider(pk) + member_role = check_member_role(request.user, service_provider) + remaining_api_key_count = 0 + + try: + account_keys = AccountAPIKey.objects.filter(service_provider=service_provider).exclude( + Q(expiry_date__lt=timezone.now()) | Q(revoked=True) + ).values_list("prefix", "name", "encrypted_key") + + if service_provider.is_certified and account_keys.count() == 0: + remaining_api_key_count = 1 + + if request.method == 'GET': + form = APIKeyGeneratorForm(request.GET or None) + + elif request.method == "POST": + if "generate_api_key" in request.POST: + if (service_provider.is_certified and remaining_api_key_count == 0): + messages.add_message( + request, messages.ERROR, + 'Your account has reached its API Key limit.' + ) + + return redirect( + "service-provider-api-key", service_provider.id + ) + form = APIKeyGeneratorForm(request.POST) + + if service_provider.is_certified and form.is_valid(): + data = form.save(commit=False) + api_key, key = AccountAPIKey.objects.create_key( + name=data.name, + service_provider_id=service_provider.id + ) + prefix = key.split(".")[0] + encrypted_key = encrypt_api_key(key) + AccountAPIKey.objects.filter( + prefix=prefix).update(encrypted_key=encrypted_key) + + else: + message = "Your account is not certified. Your account must be certified " \ + "to create API Keys." + messages.add_message( + request, + messages.ERROR, + message + ) + return redirect( + "service-provider-api-key", service_provider.id + ) + + return redirect( + "service-provider-api-key", service_provider.id + ) + + elif "delete_api_key" in request.POST: + prefix = request.POST['delete_api_key'] + api_key = AccountAPIKey.objects.filter(prefix=prefix) + api_key.delete() + + return redirect( + "service-provider-api-key", service_provider.id + ) + + context = { + "service_provider": service_provider, + "form": form, + "account_keys": account_keys, + "member_role": member_role, + "remaining_api_key_count": remaining_api_key_count, + } + return render( + request, 'account_settings_pages/_api-keys.html', context + ) + except Exception: + raise Http404() diff --git a/templates/account-base.html b/templates/account-base.html index b43d40958..816eb5ee3 100644 --- a/templates/account-base.html +++ b/templates/account-base.html @@ -1,21 +1,22 @@ {% extends 'auth-base.html' %} {% block main %} + {% include 'partials/infocards/_account-dashcard.html' %} + {% include 'partials/_subnav.html' %} + {% if community %} - {% include 'partials/infocards/_community-dashcard.html' %} - {% include 'partials/_subnav.html' %} {% block community_content %}{% endblock %} {% endif %} {% if institution %} - {% include 'partials/infocards/_institution-dashcard.html' %} - {% include 'partials/_subnav.html' %} {% block institution_content %}{% endblock %} {% endif %} {% if researcher %} - {% include 'partials/infocards/_researcher-dashcard.html' %} - {% include 'partials/_subnav.html' %} {% block researcher_content %}{% endblock %} {% endif %} + {% if service_provider %} + {% block service_provider_content %}{% endblock %} + {% endif %} + {% endblock %} \ No newline at end of file diff --git a/templates/account-settings-base.html b/templates/account-settings-base.html new file mode 100644 index 000000000..d75665277 --- /dev/null +++ b/templates/account-settings-base.html @@ -0,0 +1,129 @@ +{% extends 'auth-base.html' %} {% block title %} Update Account {% endblock %} +{% block main %} {% load static %} + + {% include 'partials/infocards/_account-dashcard.html' %} + {% include 'partials/_subnav.html' %} + + + +{% endblock %} + + + + diff --git a/templates/account_settings_pages/_api-keys.html b/templates/account_settings_pages/_api-keys.html new file mode 100644 index 000000000..0b5ac7c6c --- /dev/null +++ b/templates/account_settings_pages/_api-keys.html @@ -0,0 +1,176 @@ +{% extends 'account-settings-base.html' %}{% load static %} + +{% block account_settings %} +
+

API Key Manager

+
+{% include 'partials/_alerts.html' %} +

Generating API Keys enables access to the Local Contexts Hub database. Keep these keys confidential. For more information see our API Guide.

+ +
+

API Keys Remaining: + {% if institution and institution.is_subscribed %} + {% if remaining_api_key_count == -1 %}Unlimited{% else %}{{ remaining_api_key_count }}{% endif %} + {% elif researcher and researcher.is_subscribed %} + {% if remaining_api_key_count == -1 %}Unlimited{% else %}{{ remaining_api_key_count }}{% endif %} + {% elif service_provider and service_provider.is_certified %} + {% if remaining_api_key_count == 1 %}{{ remaining_api_key_count }}{% else %}{{ remaining_api_key_count }}{% endif %} + {% elif community and community.is_approved %}Unlimited + {% else %}0 + {% endif %} +

+
+ +{% if account_keys %} + + + + + + + + + {% for id, name, key in account_keys %} + + + + + + + {% endfor %} +
NameAPI KeyCopyDelete
{{ name }}{{ key }}
+{% elif not account_keys %} +

You do not have an API key. Click the button below to generate one.

+{% endif %} + +
+ {% if remaining_api_key_count != 0 and institution.is_subscribed %} +
+ +
+ {% elif remaining_api_key_count != 0 and researcher.is_subscribed %} +
+ +
+ {% elif remaining_api_key_count != 0 and community.is_approved %} +
+ +
+ {% elif remaining_api_key_count != 0 and service_provider.is_certified %} +
+ +
+ {% else %} +
+ + + {% if institution and not institution.is_subscribed %} + The subscription process for your institution is not completed yet. + {% elif researcher and not researcher.is_subscribed %} + The subscription process for your account is not completed yet. + {% elif service_provider and not service_provider.is_certified %} + The certification process for your account is not completed yet. Please contact us for assistance. + {% elif community and not community.is_approved %} + The confirmation process for your account is not completed yet. + {% elif institution.is_subscribed or researcher.is_subscribed and remaining_api_key_count == 0 %}Your account has reached its API Key limit. Please delete an existing key or upgrade your subscription to add more keys. + {% elif service_provider.is_certified and remaining_api_key_count == 0 %}Your account has reached its API Key limit. Please delete your existing key to generate a new one or contact us for assistance. + {% endif %} + +
+ {% endif %} +
+ + + + + + + + +{% endblock %} \ No newline at end of file diff --git a/templates/account_settings_pages/_community-boundary.html b/templates/account_settings_pages/_community-boundary.html index aa2c3ead7..70f9f86a0 100644 --- a/templates/account_settings_pages/_community-boundary.html +++ b/templates/account_settings_pages/_community-boundary.html @@ -34,7 +34,7 @@ + +{% endblock %} \ No newline at end of file diff --git a/templates/account_settings_pages/_preferences.html b/templates/account_settings_pages/_preferences.html new file mode 100644 index 000000000..fb87fb42a --- /dev/null +++ b/templates/account_settings_pages/_preferences.html @@ -0,0 +1,130 @@ +{% extends 'account-settings-base.html' %}{% load static %} + +{% block account_settings %} + +
+

Preferences

+
+{% include 'partials/_alerts.html' %} + +
+ {% csrf_token %} + + {% if not service_provider %} + +
+
+

Project Privacy for Service Providers

+

Select the Project types Service Providers can access when you connect to their account.

+
+
+ +
+
+ + +
+
+

Show Account in Service Provider Connections

+

Allow your account to be included on a Service Provider's public Registry page when you connect to their account.

+
+
+ +
+
+ {% endif %} + + {% if service_provider %} + +
+
+

Show Connections in Public View Page

+

Allow other Hub accounts that have connected to your account to be displayed on your Public Page in the Registry.

+
+
+ +
+
+ {% endif %} + +
+ +
+
+ + + +{% endblock %} \ No newline at end of file diff --git a/templates/account_settings_pages/_subscription-form.html b/templates/account_settings_pages/_subscription-form.html new file mode 100644 index 000000000..60c4d728e --- /dev/null +++ b/templates/account_settings_pages/_subscription-form.html @@ -0,0 +1,102 @@ +{% extends 'register-base.html' %} {% block title %} Create Institution {% endblock %}{% load static %} {% block card %} + + + +{% endblock %} \ No newline at end of file diff --git a/templates/account_settings_pages/_subscription.html b/templates/account_settings_pages/_subscription.html new file mode 100644 index 000000000..08796e79a --- /dev/null +++ b/templates/account_settings_pages/_subscription.html @@ -0,0 +1,53 @@ +{% extends 'account-settings-base.html' %}{% load static %} + +{% block account_settings %} +
+

Subscription

+
+

+ With a subscription, your account will have full access to the Hub’s functionality, including collaboration with Indigenous communities. Read more about subscriptions on our website. +

+ If you are interested in a subscription or discussing which subscription tier may be the best fit for you, please complete the form below. +

+ {% if subscription is not None %} +
+

Subscription Details

+
+ {% endif %} + {% if renew %} +

This subscription for this account has expired. Please renew your subscription.

+ + {% elif subscription is None %} + + {% else %} +
+ {% comment %}
Entity Type

Large Organization i + + Your institution is Large Organization. + +

{% endcomment %} +
Start Date

{{ start_date }}

+
End Date

{{ end_date }}

+
+
+

Subscription Activity

+
+

See an overview of the activities happening within your subscription

+
+
Users Remaining

{% if subscription.users_count != -1 %}{{ subscription.users_count }}{% else %}Unlimited{% endif %}

+
Projects Remaining

{% if subscription.project_count != -1 %}{{ subscription.project_count }}{% else %}Unlimited{% endif %}

+
Notifications Remaining

{% if subscription.notification_count != -1 %}{{ subscription.notification_count }}{% else %}Unlimited{% endif %}

+
Notices Remaining
{% if subscription.notification_count != -1 %}{{ subscription.notification_count }}{% else %}Unlimited{% endif %}
+
API Keys Remaining

{% if subscription.api_key_count != -1 %}{{ subscription.api_key_count }}{% else %}Unlimited{% endif %}

+
+

Need help? Please contact us at support@localcontexts.org

+ {% endif %} +{% endblock %} \ No newline at end of file diff --git a/templates/partials/_update-account-main-area.html b/templates/account_settings_pages/_update-account.html similarity index 78% rename from templates/partials/_update-account-main-area.html rename to templates/account_settings_pages/_update-account.html index ce31a658e..b89139a74 100644 --- a/templates/partials/_update-account-main-area.html +++ b/templates/account_settings_pages/_update-account.html @@ -1,4 +1,8 @@ -{% load static %} + +{% extends 'account-settings-base.html' %} {% load static %} {% block account_settings %} + + +

Account Information

@@ -6,12 +10,13 @@ {% if community %} {{ community.id }} {% endif %} {% if institution %} {{ institution.id }} {% endif %} {% if researcher %} {{ researcher.id }} {% endif %} + {% if service_provider %} {{ service_provider.id }} {% endif %}
{% csrf_token %} @@ -52,6 +57,16 @@ {% endif %} {% endif %} + + {% if service_provider %} +
+ {% if not service_provider.image %} + + {% else %} + {{ update_form.instance.name }} image + {% endif %} +
+ {% endif %}
@@ -69,8 +84,9 @@ {% endif %}
+
- {{ update_form.description.label }}
+ {{ update_form.description.label }}*
{{ update_form.description }} {% if update_form.description.errors %}
{{ update_form.description.errors.as_text }}
@@ -95,38 +111,57 @@
{{ update_form.primary_institution }}
+ {% if update_form.primary_institution.errors %} +
{{ update_form.primary_institution.errors.as_text }}
+ {% endif %} {% endif %} +
- {{ update_form.website.label }}
- {{ update_form.website }} -
- - {% if researcher %} - {% if update_form.primary_institution.errors %} -
{{ update_form.primary_institution.errors.as_text }}
+ {{ update_form.website.label }} + {% if service_provider %} +
i + A link to your platform's website +
{% endif %} +
+ {{ update_form.website }} {% if update_form.website.errors %}
{{ update_form.website.errors.as_text }}
{% endif %} + + + {% if service_provider %} + +
+ {{ update_form.documentation.label }} +
i + A link to your platform's documentation of the Local Contexts integration +
+
+ {{ update_form.documentation }} + {% if update_form.documentation.errors %} +
+ {{ update_form.documentation.errors.as_text }} +
+ {% endif %} +
{% endif %} {% if institution or community %} -
-
- {{ update_form.country.label }}
+
+
+ {{ update_form.country.label }} {{ update_form.country }}
-
-
-
- {{ update_form.state_province_region }} -
-
-
- {{ update_form.city_town }} -
+
+ + {{ update_form.state_province_region }} +
+
+ + {{ update_form.city_town }}
{% if update_form.state_province_region.errors %}
{{ update_form.state_province_region.errors.as_text }}
@@ -162,9 +197,9 @@
{% endif %} - +
-

ORCiD

+

ORCID

{% if researcher.orcid %}
@@ -200,14 +235,14 @@
{% else %}
-

We encourage you to have an ORCiD ID.
You can read more about ORCiD here.

- +

We encourage you to have an ORCID ID.
You can read more about ORCID here.

+
{% if env == "DEV" or env == "SANDBOX" %} - Please note that since this is a test site, the ORCiD used will be the sandbox version.
+ Please note that since this is a test site, the ORCID used will be the sandbox version.
{% endif %} {% endif %} @@ -239,7 +274,7 @@ {% include 'partials/_alerts.html' %}
-

+

Providing your personal information is optional. By providing it, you consent for it to be shown to other Hub users, see the Privacy Policy .

@@ -247,4 +282,6 @@
- \ No newline at end of file + + +{% endblock %} diff --git a/templates/accounts/apikey.html b/templates/accounts/apikey.html deleted file mode 100644 index 1c749b00b..000000000 --- a/templates/accounts/apikey.html +++ /dev/null @@ -1,129 +0,0 @@ -{% extends 'settings-base.html' %}{% load static %} - -{% block settings %} -
-
-

API Key Manager

-
- - {% if has_key %} -
-

Your API key enables access to the Local Contexts Hub data via the API and must be kept secret. For more information see our API Guide. -

Your API key is listed below.

-
- -
-
-
-
-
{{ api_key }}
- {% if keyvisible %} -
- {% csrf_token %} - -
- {% else %} - - {% endif %} - -
-
-
-
- {% csrf_token %} - {% if keyvisible %} - - {% else %} - -
{% include 'partials/_alerts.html' %}
- {% endif %} -
-
-
- -
-

Delete API Key

- -
-

You can only have one API key at a time. If you need to generate a new one, you must delete this one first. To delete your current key, click the button below.

- -
-
- - - {% else %} -
-
- {% csrf_token %} - -
-

Your API key enables access to the Local Contexts Hub data via the API and must be kept secret. For more information see our API Guide.

-

You do not have an API key. Click the button below to generate one.

- -
-
-
- {% include 'partials/_alerts.html' %} - {% endif %} - -
- - - -{% endblock %} \ No newline at end of file diff --git a/templates/accounts/change-password.html b/templates/accounts/change-password.html index 1942d0fe7..9c78f9c63 100644 --- a/templates/accounts/change-password.html +++ b/templates/accounts/change-password.html @@ -1,26 +1,24 @@ {% extends 'settings-base.html' %} {% load static %} {% block settings %} -
-

Change Password

+

Change Password

-
-
- {% csrf_token %} +
+ + {% csrf_token %} - {{ form.as_p }} + {{ form.as_p }} - {% if form.errors %} - {{ form.errors }} - {% endif %} + {% if form.errors %} + {{ form.errors }} + {% endif %} - {% include 'partials/_alerts.html' %} - -
- -
- -
+ {% include 'partials/_alerts.html' %} +
+ +
+
+ {% endblock %} \ No newline at end of file diff --git a/templates/accounts/confirm-subscription.html b/templates/accounts/confirm-subscription.html new file mode 100644 index 000000000..3b9e56410 --- /dev/null +++ b/templates/accounts/confirm-subscription.html @@ -0,0 +1,173 @@ +{% extends 'register-base.html' %} {% block title %} Subscription {% endblock%} {% load static %} {% block card %} + + +{% if user.is_authenticated %} + +
+
+

Subscription Information

+
+
+

+ The information you provide here will be shared with other accounts you decide to join to help them identify who you are. +

+
+
+ +
+
+ {% csrf_token %} +
+
+
+
+ + {{ form.first_name }}{% if form.first_name.errors %} +
+ {{ form.first_name.errors.as_text }} +
+ {% endif %} +
+
+ + {{ form.last_name }} + {% if form.last_name.errors %} +
+ {{ form.first_name.errors.as_text }} +
+ {% endif %} +
+
+ +
+
+ + {{ form.email }}{% if form.email.errors %} +
+ {{ form.email.errors.as_text }} +
+ {% endif %} +
+ +
+ +
+
+ + {{ form.account_type }} + {% if form.account_type.errors %} +
+ {{ form.account_type.errors.as_text }} +
+ {% endif %} +
+
+ + {{ form.inquiry_type }} + {% if form.inquiry_type.errors %} +
+ {{ form.inquiry_type.errors.as_text }} +
+ {% endif %} +
+ +
+
+
+ + {{ form.organization_name }} + {% if form.organization_name.errors %} +
+ {{ form.organization_name.errors.as_text }} +
+ {% endif %} +
+
+
+ + {% include 'partials/_alerts.html' %} +
+ +
+ Continue + +
+
+
+
+ +
+
+{% endif %} +{% if join_flag %} + +{% endif %} + +{% endblock %} \ No newline at end of file diff --git a/templates/accounts/dashboard.html b/templates/accounts/dashboard.html index 774583ed1..98b1f1de0 100644 --- a/templates/accounts/dashboard.html +++ b/templates/accounts/dashboard.html @@ -18,8 +18,16 @@
- {% if not researcher and not user_communities and not user_institutions %} -
+ {% if not researcher and not user_communities and not user_institutions and not user_service_providers %} +
+

+ There are no accounts yet. +
+ + Click the link above to create an account or browse the registry to join an existing account. + +

+
{% endif %} {% if researcher %} @@ -38,6 +46,11 @@ {% endfor %} {% endif %} -
+ {% if user_service_providers %} + {% for service_provider in user_service_providers %} + {% include 'partials/infocards/_service-provider-card.html' %} + {% endfor %} + {% endif %} + {% endblock %} \ No newline at end of file diff --git a/templates/accounts/link-account.html b/templates/accounts/link-account.html index 32e2039e5..9831d26db 100644 --- a/templates/accounts/link-account.html +++ b/templates/accounts/link-account.html @@ -2,49 +2,47 @@ {% load socialaccount %} {% block settings %} -
-
-

Linked Accounts

-

Manage, link or remove accounts connected to this account.

-
- - {% if socialaccount and provider == 'google' %} -
- {% csrf_token %} -
- -
-
- {% else %} -
- {% csrf_token %} -
- -
-
- {% endif %} +
+

Linked Accounts

+

Manage, link or remove accounts connected to this account.

+
- {% include 'partials/_alerts.html' %} +{% if socialaccount and provider == 'google' %} +
+ {% csrf_token %} +
+ +
+
+{% else %} +
+ {% csrf_token %} +
+ +
+
+{% endif %} -