diff --git a/backend/dataset/migrations/0046_merge_20240416_2233.py b/backend/dataset/migrations/0046_merge_20240416_2233.py new file mode 100644 index 000000000..24617f1e7 --- /dev/null +++ b/backend/dataset/migrations/0046_merge_20240416_2233.py @@ -0,0 +1,12 @@ +# Generated by Django 3.2.14 on 2024-04-16 17:03 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("dataset", "0045_alter_ocrdocument_ocr_domain"), + ("dataset", "0045_auto_20240321_0949"), + ] + + operations = [] diff --git a/backend/dataset/views.py b/backend/dataset/views.py index 050a1bfda..5c9e1fa21 100644 --- a/backend/dataset/views.py +++ b/backend/dataset/views.py @@ -305,7 +305,7 @@ def download(self, request, pk): URL: /data/instances//download/ Accepted methods: GET """ - export_type = request.GET.get("type", "csv") + export_type = request.GET.get("export_type", "csv").lower() try: # Get the dataset instance for the id dataset_instance = DatasetInstance.objects.get(instance_id=pk) @@ -314,6 +314,19 @@ def download(self, request, pk): dataset_model = apps.get_model("dataset", dataset_instance.dataset_type) data_items = dataset_model.objects.filter(instance_id=pk) + field_names = set([field.name for field in dataset_model._meta.get_fields()]) + for key, value in request.GET.items(): + if key in field_names: + kwargs = {f"{key}__icontains": value} + data_items = data_items.filter(**kwargs) + elif key == "export_type": + continue + else: + return Response( + {"message": "The corresponding column does not exist"}, + status=status.HTTP_400_BAD_REQUEST, + ) + dataset_resource = resources.RESOURCE_MAP[dataset_instance.dataset_type] exported_items = dataset_resource().export_as_generator(export_type, data_items) if export_type == "tsv": diff --git a/backend/organizations/models.py b/backend/organizations/models.py index afa16929c..e8a37f439 100644 --- a/backend/organizations/models.py +++ b/backend/organizations/models.py @@ -4,10 +4,11 @@ from shoonya_backend.settings import AUTH_USER_MODEL from shoonya_backend.mixins import DummyModelMixin import secrets -from django.core.mail import send_mail +from django.core.mail import EmailMultiAlternatives import os from dotenv import load_dotenv + load_dotenv() from django.conf import settings @@ -120,6 +121,139 @@ def __str__(self): + " organization" ) + @classmethod + def send_invite_email(cls, invite, user): + current_environment = os.getenv("ENV") + base_url = ( + "dev.shoonya.ai4bharat.org" + if current_environment == "dev" + else "shoonya.ai4bharat.org" + ) + subject = "Invitation to join Organization" + invite_link = f"https://{base_url}/#/invite/{invite.invite_code}" + text_content = f"Hello! You are invited to Shoonya. Your Invite link is: " + style_string = """ + *{ margin: 0; + padding: 0; + } + body { + font-family: "Arial", sans-serif; + background-color: #f2f8f8; + margin: 0; + padding: 0; + padding-top: 2rem; + } + .container { + background-color: #fff; + border: solid 1px #e1e1e1; + border-radius: 2px; + padding: 1.4rem; + max-width: 380px; + margin: auto; + } + .header { + width: fit-content; + margin: auto; + } + h1 { + font-size: 1.2rem; + font-weight: 300; + margin: 1rem 0; + font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif; + } + p { + font-size: 0.9rem; + color: #222; + margin: 0.8rem 0; + } + .primary { + color: #18621f; + } + .footer { + margin-top: 1rem; + font-size: 0.9rem; + } + .footer > * { + font-size: inherit; + }""" + + html_content = f""" + + + + + + Invitation to join Shoonya Organisation + + + +
+
+

Invitaiton to join Shoonya

+
+
+
+ + + + + + + +
+ + Join Shoonya Now + +
+
+
+

+ Please use the above link to verify your email address and complete your registration. +

+

+ For security purposes, please do not share the this link with + anyone. +

+

+ If clicking the link doesn't work, you can copy and paste the link into your browser's address window, or retype it there. + {invite_link} +

+
+
+ +
+ + + """ + msg = EmailMultiAlternatives( + subject, text_content, settings.DEFAULT_FROM_EMAIL, [user.email] + ) + msg.attach_alternative(html_content, "text/html") + msg.send() + @classmethod def create_invite(cls, organization=None, users=None): with transaction.atomic(): @@ -130,12 +264,7 @@ def create_invite(cls, organization=None, users=None): invite = Invite.objects.create(organization=organization, user=user) invite.invite_code = cls.generate_invite_code() invite.save() - send_mail( - "Invitation to join Organization", - f"Hello! You are invited to {organization.title}. Your Invite link is: https://shoonya.ai4bharat.org/#/invite/{invite.invite_code}", - settings.DEFAULT_FROM_EMAIL, - [user.email], - ) + cls.send_invite_email(invite, user) # def has_permission(self, user): # if self.organization.created_by.pk == user.pk or user.is_superuser: @@ -147,12 +276,7 @@ def re_invite(cls, users=None): with transaction.atomic(): for user in users: invite = Invite.objects.get(user=user) - send_mail( - "Invitation to join Organization", - f"Hello! You are invited to {invite.organization.title}. Your Invite link is: https://shoonya.ai4bharat.org/#/invite/{invite.invite_code}", - settings.DEFAULT_FROM_EMAIL, - [user.email], - ) + cls.send_invite_email(invite, user) @classmethod def generate_invite_code(cls): diff --git a/backend/projects/tasks.py b/backend/projects/tasks.py index 2652ac83a..8f0bd51c1 100644 --- a/backend/projects/tasks.py +++ b/backend/projects/tasks.py @@ -379,7 +379,7 @@ def create_parameters_for_task_creation( tasks = create_tasks_from_dataitems(sampled_items, project) -# @shared_task +@shared_task def export_project_in_place( annotation_fields, project_id, project_type, get_request_data ) -> None: diff --git a/backend/shoonya_backend/settings.py b/backend/shoonya_backend/settings.py index e8f6e71d7..2915ce894 100644 --- a/backend/shoonya_backend/settings.py +++ b/backend/shoonya_backend/settings.py @@ -41,6 +41,7 @@ "shoonya.ai4bharat.org", "0.0.0.0", "backend.shoonya.ai4bharat.org", + "backend.shoonya2.ai4bharat.org", ] # Application definition @@ -186,12 +187,13 @@ # Email Settings -EMAIL_BACKEND = "django_smtp_ssl.SSLEmailBackend" +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" EMAIL_HOST = os.getenv("EMAIL_HOST") -EMAIL_PORT = 465 +EMAIL_PORT = os.getenv("EMAIL_PORT") EMAIL_HOST_USER = os.getenv("SMTP_USERNAME") EMAIL_HOST_PASSWORD = os.getenv("SMTP_PASSWORD") EMAIL_USE_TLS = True +EMAIL_USE_SSL = False DEFAULT_FROM_EMAIL = os.getenv("DEFAULT_FROM_EMAIL") DOMAIN = "shoonya.ai4bharat.org" diff --git a/backend/tasks/views.py b/backend/tasks/views.py index 43517ac06..50de59c54 100644 --- a/backend/tasks/views.py +++ b/backend/tasks/views.py @@ -1740,15 +1740,6 @@ def partial_update(self, request, pk=None): if annotation_obj.annotation_status == TO_BE_REVISED: update_notification(annotation_obj, task) is_revised = True - print(annotation_obj) - if "ids" in dict(request.data): - pass - - else: - return Response( - {"message": "key doesnot match"}, - status=status.HTTP_400_BAD_REQUEST, - ) elif annotation_obj.annotation_type == SUPER_CHECKER_ANNOTATION: is_rejected = False diff --git a/backend/users/migrations/0032_auto_20240417_1028.py b/backend/users/migrations/0032_auto_20240417_1028.py new file mode 100644 index 000000000..5bbd87b9e --- /dev/null +++ b/backend/users/migrations/0032_auto_20240417_1028.py @@ -0,0 +1,34 @@ +# Generated by Django 3.2.14 on 2024-04-17 04:58 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0031_user_notification_limit"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="approved_by", + field=models.ForeignKey( + blank=True, + default=1, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="user", + name="is_approved", + field=models.BooleanField( + default=True, + help_text="Indicates whether user is approved by the admin or not.", + verbose_name="is_approved", + ), + ), + ] diff --git a/backend/users/migrations/0033_rename_approved_by_user_invited_by.py b/backend/users/migrations/0033_rename_approved_by_user_invited_by.py new file mode 100644 index 000000000..afafab796 --- /dev/null +++ b/backend/users/migrations/0033_rename_approved_by_user_invited_by.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.14 on 2024-04-22 14:02 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0032_auto_20240417_1028"), + ] + + operations = [ + migrations.RenameField( + model_name="user", + old_name="approved_by", + new_name="invited_by", + ), + ] diff --git a/backend/users/models.py b/backend/users/models.py index 1dff1cb2a..c391d5f23 100644 --- a/backend/users/models.py +++ b/backend/users/models.py @@ -213,6 +213,22 @@ class User(AbstractBaseUser, PermissionsMixin): "Indicates whether user prefers Chitralekha UI for audio transcription tasks or not." ), ) + # def get_default_user(): + # return settings.AUTH_USER_MODEL.objects.get(id=1) + + is_approved = models.BooleanField( + verbose_name="is_approved", + default=False, + help_text=("Indicates whether user is approved by the admin or not."), + ) + + invited_by = models.ForeignKey( + "self", + on_delete=models.SET_NULL, + null=True, + blank=True, + default=1, + ) class Meta: db_table = "user" diff --git a/backend/users/serializers.py b/backend/users/serializers.py index 414388300..98048b15a 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -29,6 +29,35 @@ def update(self, instance, validated_data): return instance +class UsersPendingSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = [ + "id", + "username", + "first_name", + "last_name", + "email", + "role", + "invited_by", + "has_accepted_invite", + ] + + def update(self, instance, validated_data): + instance.id = validated_data.get("id", instance.id) + instance.username = validated_data.get("username", instance.username) + instance.first_name(validated_data.get("first_name", instance.first_name)) + instance.last_name(validated_data.get("last_name", instance.last_name)) + instance.email(validated_data.get("email", instance.email)) + instance.role(validated_data.get("role", instance.role)) + instance.invited_by(validated_data.get("invited_by", instance.invited_by)) + instance.has_accepted_invite( + validated_data.get("has_accepted_invite", instance.has_accepted_invite) + ) + instance.save() + return instance + + class UserUpdateSerializer(serializers.ModelSerializer): organization = OrganizationSerializer() diff --git a/backend/users/views.py b/backend/users/views.py index d99e4ab15..cbaa32455 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -20,6 +20,7 @@ UserLoginSerializer, UserProfileSerializer, UserSignUpSerializer, + UsersPendingSerializer, UserUpdateSerializer, LanguageSerializer, ChangePasswordSerializer, @@ -59,7 +60,7 @@ from rest_framework_simplejwt.tokens import RefreshToken from dotenv import load_dotenv import logging -from workspaces.views import WorkspaceViewSet +from workspaces.views import WorkspaceusersViewSet logger = logging.getLogger(__name__) logging.basicConfig(level=logging.INFO) @@ -107,11 +108,15 @@ def invite_users(self, request): organization_id=org.id, role=request.data.get("role"), ) + user.is_approved = True user.set_password(generate_random_string(10)) valid_user_emails.append(email) users.append(user) except: - pass + return Response( + {"message": "Error in creating user"}, + status=status.HTTP_400_BAD_REQUEST, + ) else: invalid_emails.append(email) # setting error messages @@ -259,6 +264,7 @@ def sign_up_user(self, request, pk=None): try: user = User.objects.get(email=email) except User.DoesNotExist: + user.is_approved = False return Response( {"message": "User not found"}, status=status.HTTP_404_NOT_FOUND ) @@ -279,6 +285,190 @@ def sign_up_user(self, request, pk=None): serialized.save() return Response({"message": "User signed up"}, status=status.HTTP_200_OK) + # 1 add users to workspace - workspace name + # 2. Invite new users to {organisation name} + # function to list the users whose user.is_approved is false + @permission_classes([IsAuthenticated]) + @swagger_auto_schema(responses={200: UsersPendingSerializer}) + @action(detail=False, methods=["get"], url_path="pending_users") + def pending_users(self, request): + """ + List of users who have not accepted the invite yet in that organisation/workspace + """ + organisation_id = request.query_params.get("organisation_id") + users = User.objects.filter(organization_id=organisation_id, is_approved=False) + + # demo_user = User.objects.filter(id=1) + # filtered_user = demo_user.values_list("email", flat=True) + # # Convert QuerySet to list and get first element + # email = list(filtered_user)[0] + # print(email) + # print(request.user) + serialized = UsersPendingSerializer(users, many=True) + + if serialized.data: + return Response(serialized.data, status=status.HTTP_200_OK) + + return Response({"message": "No pending users"}, status=status.HTTP_200_OK) + + # function to reject the user request to join the workspace by organiastion owner and delete the user from the table + @permission_classes([IsAuthenticated]) + @is_organization_owner + @swagger_auto_schema(request_body=UsersPendingSerializer) + @action(detail=False, methods=["delete"], url_path="reject_user") + def reject_user(self, request): + """ + Reject the user request to join the workspace + """ + try: + user_id = request.query_params.get("userId", None) + user = User.objects.get(id=user_id) + + if user.is_approved == True: + return Response( + {"message": "User is already approved"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except User.DoesNotExist: + return Response( + {"message": "User not found"}, status=status.HTTP_404_NOT_FOUND + ) + user.delete() + return Response({"message": "User rejected"}, status=status.HTTP_200_OK) + + # function to approve the user request to join the workspace by organiastion owner and update the user.is_approved to true + @permission_classes([IsAuthenticated]) + @is_organization_owner + @swagger_auto_schema(request_body=UsersPendingSerializer) + @action(detail=False, methods=["post"], url_path="approve_user") + def approve_user(self, request): + """ + Approve the user request to join the workspace + """ + try: + user_id = request.query_params.get("userId", None) + user = User.objects.get(id=user_id) + organisation_id = user.organization_id + + try: + organisation = Organization.objects.get(id=organisation_id) + except Organization.DoesNotExist: + return Response( + {"message": "Organization not found"}, + status=status.HTTP_404_NOT_FOUND, + ) + if user.is_approved == True: + return Response( + {"message": "User is already approved"}, + status=status.HTTP_400_BAD_REQUEST, + ) + user.is_approved = True + user.save() + # invite the user via mail now + try: + users = [] + users.append(user) + Invite.create_invite(organization=organisation, users=users) + except Exception as e: + return Response( + {"message": f"Error in sending invite: {str(e)}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + except User.DoesNotExist: + return Response( + {"message": "User not found"}, status=status.HTTP_404_NOT_FOUND + ) + return Response({"message": "User approved"}, status=status.HTTP_200_OK) + + # function to request workspace owner to add the users to the workspace by workspace manager + @permission_classes([IsAuthenticated]) + @swagger_auto_schema(request_body=InviteGenerationSerializer) + @action(detail=False, methods=["post"], url_path="request_user") + def request_user(self, request): + """ + Request the workspace owner to add the user to the workspace from manager + """ + all_emails = request.data.get("emails") + distinct_emails = list(set(all_emails)) + organization_id = request.data.get("organization_id") + users = [] + + try: + org = Organization.objects.get(id=organization_id) + except Organization.DoesNotExist: + return Response( + {"message": "Organization not found"}, status=status.HTTP_404_NOT_FOUND + ) + already_existing_emails = [] + valid_user_emails = [] + invalid_emails = [] + existing_emails_set = set(Invite.objects.values_list("user__email", flat=True)) + + for email in distinct_emails: + # Checking if the email is in valid format. + if re.fullmatch(regex, email): + if email in existing_emails_set: + already_existing_emails.append(email) + continue + try: + user = User( + username=generate_random_string(12), + email=email.lower(), + organization_id=org.id, + role=request.data.get("role"), + has_accepted_invite=False, + is_approved=False, + ) + user.set_password(generate_random_string(10)) + valid_user_emails.append(email) + users.append(user) + except: + pass + else: + invalid_emails.append(email) + # setting error messages + ( + additional_message_for_existing_emails, + additional_message_for_invalid_emails, + ) = ("", "") + additional_message_for_valid_emails = "" + if already_existing_emails: + additional_message_for_existing_emails += ( + f", Invites already sent to: {','.join(already_existing_emails)}" + ) + if invalid_emails: + additional_message_for_invalid_emails += ( + f", Invalid emails: {','.join(invalid_emails)}" + ) + if valid_user_emails: + additional_message_for_valid_emails += ( + f", Requested users : {','.join(valid_user_emails)}" + ) + if len(valid_user_emails) == 0: + return Response( + { + "message": "No Requests sent" + + additional_message_for_invalid_emails + + additional_message_for_existing_emails + }, + status=status.HTTP_400_BAD_REQUEST, + ) + elif len(invalid_emails) == 0: + ret_dict = { + "message": "The invites to this users will be sent after approval from the organization owner" + + additional_message_for_valid_emails + + additional_message_for_existing_emails + } + else: + ret_dict = { + "message": f"Request sent partially!" + + additional_message_for_valid_emails + + additional_message_for_invalid_emails + + additional_message_for_existing_emails + } + users = User.objects.bulk_create(users) + return Response(ret_dict, status=status.HTTP_201_CREATED) + class AuthViewSet(viewsets.ViewSet): @permission_classes([AllowAny]) @@ -683,23 +873,48 @@ def user_details_update(self, request, pk=None): pass else: if is_active_payload is False: + if user.enable_mail: + user.enable_mail = False + user.save() workspaces = Workspace.objects.filter( Q(members=user) | Q(managers=user) - ) - workspace_view = WorkspaceViewSet() + ).distinct() + + workspacecustomviewset_obj = WorkspaceCustomViewSet() + request.data["ids"] = [user.id] + + workspaceusersviewset_obj = WorkspaceusersViewSet() + request.data["user_id"] = user.id + for workspace in workspaces: - workspace_view.unassign_manager( - request, pk=workspace.pk, ids=[user.id] + workspacecustomviewset_obj.unassign_manager( + request=request, pk=workspace.pk ) - workspace_view.remove_members( - request, pk=workspace.pk, user_id=user.id + + workspaceusersviewset_obj.remove_members( + request=request, pk=workspace.pk ) + user.is_active = False + user.save() return Response( { "message": "User removed from all workspaces both as workspace member and workspace manager" }, status=status.HTTP_200_OK, ) + else: + if is_active_payload is True: + workspaces = Workspace.objects.filter( + Q(members=user) | Q(managers=user) + ).distinct() + + workspaceusersviewset_obj = WorkspaceusersViewSet() + request.data["user_id"] = user.id + + for workspace in workspaces: + workspaceusersviewset_obj.remove_frozen_user( + request=request, pk=workspace.pk + ) if request.data["role"] != user.role: new_role = int(request.data["role"])