notifications 14-03-2024 13:48

This commit is contained in:
rizwanisready
2024-03-14 13:48:41 +05:30
parent 01469b41c6
commit deee5e212c
21 changed files with 450 additions and 121 deletions

View File

@@ -37,6 +37,7 @@ def compute_resource_action_constants():
'RESOURCE_MANAGE_CMS': resource_action.RESOURCE_MANAGE_CMS,
'RESOURCE_MANAGE_REPORTS': resource_action.RESOURCE_MANAGE_REPORTS,
'RESOURCE_MANAGE_SUBSCRIPTIONS': resource_action.RESOURCE_MANAGE_SUBSCRIPTIONS,
'RESOURCE_MANAGE_NOTIFICATIONS': resource_action.RESOURCE_MANAGE_NOTIFICATIONS,
'RESOURCE_MANAGE_REFERRALS': resource_action.RESOURCE_MANAGE_REFERRALS,
'RESOURCE_MANAGE_FEEDBACK': resource_action.RESOURCE_MANAGE_FEEDBACK,
'RESOURCE_IAM_PRINCIPAL': resource_action.RESOURCE_IAM_PRINCIPAL,

View File

@@ -15,7 +15,7 @@ from manage_referrals.models import (
ReferralTracking,
)
from manage_subscriptions.models import PrincipalSubscription, Subscription
from manage_wallets.models import TransactionStatus, Wallet, Transaction
from manage_wallets.models import TransactionStatus, TransactionType, Wallet, Transaction
from goodtimes.utils import CapacityError, RandomGenerator
from manage_events.models import (
Event,
@@ -281,6 +281,16 @@ class PaymentProcessingService:
wallet, created = Wallet.objects.get_or_create(principal=referrer_principal)
wallet.coins += 1
wallet.save()
Transaction.objects.create(
principal=referrer_principal,
transaction_type=TransactionType.CREDIT,
payment_method="",
transaction_status=TransactionStatus.SUCCESS,
amount=0,
coin=1,
comment="Referral reward",
# Populate other fields as necessary, such as `order_id`, `product_id`, or `reference_id` if applicable
)
def _handle_failure(self):
# Implement any necessary logic to handle a failed payment

View File

@@ -64,7 +64,7 @@ LOCAL_APPS = [
"manage_referrals",
"manage_cms",
"manage_communications", # for contact us, and feedback
"manage_notifications",
"manage_notifications.apps.ManageNotificationsConfig",
"chat",
]

View File

@@ -54,7 +54,8 @@ urlpatterns = [
path("api/subscriptions/", include("manage_subscriptions.api.urls")),
path("notifications/", include("manage_notifications.urls")),
path("api/notifications/", include("manage_notifications.api.urls")),
# path('', include('manage_notifications.urls')),
# path('api/', include("accounts.api.urls")),
]

View File

@@ -1,3 +1,32 @@
from django.contrib import admin
from .models import PushNotification, IAmPrincipalNotificationSettings
# Register your models here.
class PushNotificationAdmin(admin.ModelAdmin):
list_display = (
"id",
"title",
"notification_category",
"principal_type",
"banner_image",
"message",
)
search_fields = ("title", "notification_category", "principal_type")
list_filter = ("notification_category", "principal_type")
admin.site.register(PushNotification, PushNotificationAdmin)
class IAmPrincipalNotificationSettingsAdmin(admin.ModelAdmin):
list_display = ("id", "principal", "notification_category", "is_enabled")
search_fields = ("principal__first_name", "notification_category")
list_filter = ("notification_category", "is_enabled")
raw_id_fields = (
"principal",
) # This is useful if you have many users and a dropdown becomes impractical
admin.site.register(
IAmPrincipalNotificationSettings, IAmPrincipalNotificationSettingsAdmin
)

View File

@@ -0,0 +1,13 @@
from rest_framework import serializers
from manage_notifications.models import IAmPrincipalNotificationSettings, NotificationCategoryChoices
class IAmPrincipalNotificationSettingsSerializer(serializers.ModelSerializer):
notification_category_display = serializers.SerializerMethodField()
def get_notification_category_display(self, obj):
return obj.get_notification_category_display()
class Meta:
model = IAmPrincipalNotificationSettings
fields = ['id', 'notification_category', 'notification_category_display', 'is_enabled']

View File

@@ -0,0 +1,17 @@
from django.urls import path
from . import views
app_name = "manage_notifications_api"
urlpatterns = [
path(
"toggle-notification-setting/",
views.NotificationSettingToggle.as_view(),
name="toggle-notification-setting",
),
path(
"user-notifications/",
views.UserNotificationsAPIView.as_view(),
name="user-notifications",
),
]

View File

@@ -0,0 +1,75 @@
from rest_framework import status
from rest_framework.views import APIView
from django.conf import settings
from manage_notifications.api.serializers import (
IAmPrincipalNotificationSettingsSerializer,
)
from manage_notifications.models import (
IAmPrincipalNotificationSettings,
NotificationCategoryChoices,
)
from goodtimes import constants
from goodtimes.utils import ApiResponse
from rest_framework.permissions import IsAuthenticated
from rest_framework_simplejwt.authentication import JWTAuthentication
class NotificationSettingToggle(APIView):
authentication_classes = [JWTAuthentication]
permission_classes = [IsAuthenticated]
def post(self, request, *args, **kwargs):
notification_category = request.data.get("notification_category")
enable = request.data.get("enable", True)
if (
not notification_category
or notification_category not in NotificationCategoryChoices.values
):
return ApiResponse.error(
errors="Invalid notification category",
message=constants.FAILURE,
status=status.HTTP_400_BAD_REQUEST,
)
# Assuming IAmPrincipal model has a user field that relates to Django's User model
# You might need to adjust the query depending on your user-principal relationship
notification_setting, created = (
IAmPrincipalNotificationSettings.objects.get_or_create(
principal=request.user,
notification_category=notification_category,
defaults={"is_enabled": enable},
)
)
if not created:
notification_setting.is_enabled = enable
notification_setting.save()
return ApiResponse.success(
data=f"{notification_category}: { enable}.",
message=constants.SUCCESS,
status=status.HTTP_200_OK,
)
class UserNotificationsAPIView(APIView):
authentication_classes = [JWTAuthentication]
permission_classes = [IsAuthenticated]
def get(self, request, *args, **kwargs):
# Assuming your IAmPrincipal model has a user field linking to the User model
principal = request.user
notifications = (
IAmPrincipalNotificationSettings.objects.filter(principal=principal)
.exclude(notification_category=NotificationCategoryChoices.GENERAL)
.exclude(notification_category=NotificationCategoryChoices.PROMOTIONS)
)
serializer = IAmPrincipalNotificationSettingsSerializer(
notifications, many=True
)
return ApiResponse.success(
data=serializer.data,
message=constants.SUCCESS,
status=status.HTTP_200_OK,
)

View File

@@ -4,3 +4,7 @@ from django.apps import AppConfig
class ManageNotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "manage_notifications"
def ready(self):
# Import signal handlers here
from . import signals

View File

@@ -1,5 +1,5 @@
from django import forms
from .models import PushNotification, NotificationCategory, PrincipalType
from .models import NotificationCategoryChoices, PushNotification
class PushNotificationForm(forms.ModelForm):
@@ -15,3 +15,11 @@ class PushNotificationForm(forms.ModelForm):
widgets = {
"message": forms.Textarea(attrs={"rows": 4}),
}
def __init__(self, *args, **kwargs):
super(PushNotificationForm, self).__init__(*args, **kwargs)
# Limit choices for notification_category to GENERAL and PROMOTIONS only
self.fields["notification_category"].choices = [
(NotificationCategoryChoices.GENERAL, "General"),
(NotificationCategoryChoices.PROMOTIONS, "Promotions"),
]

View File

@@ -0,0 +1,68 @@
# Generated by Django 5.0.2 on 2024-03-13 19:07
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("manage_notifications", "0001_initial"),
]
operations = [
migrations.AlterField(
model_name="pushnotification",
name="notification_category",
field=models.CharField(
choices=[
("general", "General"),
("transaction", "Transaction"),
("referral", "Referral"),
("subscription", "Subscription"),
("event", "Event"),
("promotions", "Promotions"),
],
max_length=50,
),
),
migrations.RemoveField(
model_name="notificationsettings",
name="category",
),
migrations.RemoveField(
model_name="notificationsettings",
name="created_by",
),
migrations.RemoveField(
model_name="notificationsettings",
name="modified_by",
),
migrations.RemoveField(
model_name="iamprincipalnotificationsettings",
name="notification_setting",
),
migrations.AddField(
model_name="iamprincipalnotificationsettings",
name="notification_category",
field=models.CharField(
choices=[
("general", "General"),
("transaction", "Transaction"),
("referral", "Referral"),
("subscription", "Subscription"),
("event", "Event"),
("promotions", "Promotions"),
],
default=django.utils.timezone.now,
max_length=50,
),
preserve_default=False,
),
migrations.DeleteModel(
name="NotificationCategory",
),
migrations.DeleteModel(
name="NotificationSettings",
),
]

View File

@@ -1,16 +1,18 @@
from django.db import models
from accounts.models import BaseModel, IAmPrincipal, IAmPrincipalType
from django.db.models.signals import post_save
from django.dispatch import receiver
from manage_wallets.models import Wallet
# Create your models here.
class NotificationCategory(BaseModel):
name = models.CharField(max_length=100)
class Meta:
db_table = "notification_settings_category"
def __str__(self) -> str:
return f"name : {self.name}"
class NotificationCategoryChoices(models.TextChoices):
GENERAL = "general", "General"
TRANSACTION = "transaction", "Transaction"
REFERRAL = "referral", "Referral"
SUBSCRIPTION = "subscription", "Subscription"
EVENT = "event", "Event"
PROMOTIONS = "promotions", "Promotions"
class PrincipalType(models.TextChoices):
@@ -21,10 +23,9 @@ class PrincipalType(models.TextChoices):
class PushNotification(BaseModel):
title = models.CharField(max_length=255)
notification_category = models.ForeignKey(
NotificationCategory,
on_delete=models.CASCADE,
related_name="push_category",
notification_category = models.CharField(
max_length=50,
choices=NotificationCategoryChoices.choices,
)
banner_image = models.ImageField(
upload_to="push_notification_images/", blank=True, null=True
@@ -39,27 +40,13 @@ class PushNotification(BaseModel):
return self.title
class NotificationSettings(BaseModel):
name = models.CharField(max_length=100)
category = models.ForeignKey(
NotificationCategory,
on_delete=models.CASCADE,
related_name="notification_category",
)
class Meta:
db_table = "notification_settings"
def __str__(self) -> str:
return f"name: {self.name}"
class IAmPrincipalNotificationSettings(BaseModel):
principal = models.ForeignKey(
IAmPrincipal, on_delete=models.CASCADE, related_name="notifications_principal"
)
notification_setting = models.ForeignKey(
NotificationSettings, on_delete=models.CASCADE
notification_category = models.CharField(
max_length=50,
choices=NotificationCategoryChoices.choices,
)
is_enabled = models.BooleanField(default=True)
@@ -67,4 +54,4 @@ class IAmPrincipalNotificationSettings(BaseModel):
db_table = "iam_principal_notification_settings"
def __str__(self):
return f"{self.principal.first_name} - {self.notification_setting.name}"
return f"{self.principal.first_name} - {self.notification_category}"

View File

@@ -0,0 +1,16 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from manage_wallets.models import Wallet
from .models import IAmPrincipalNotificationSettings, NotificationCategoryChoices
@receiver(post_save, sender=Wallet)
def create_default_notification_settings(sender, instance, created, **kwargs):
if created:
principal = instance.principal
# Assuming NotificationCategoryChoices is a list of tuples as choices for the field
for category in NotificationCategoryChoices:
IAmPrincipalNotificationSettings.objects.create(
principal=principal,
notification_category=category.value, # Assuming category is a tuple and the value is at index 0
is_enabled=True
)

View File

@@ -1,88 +1,69 @@
# import requests
# from django.conf import settings
# def onesignal_send_notification(notification):
# onesignal_app_id = settings.ONE_SIGNAL_APP_ID
# onesignal_rest_api_key = settings.ONE_SIGNAL_API_KEY
# headers = {
# "Content-Type": "application/json; charset=utf-8",
# "Authorization": f"Basic {onesignal_rest_api_key}"
# }
# # Determine player IDs based on notification_category and principal_type
# player_ids = []
# if notification.principal_type == PrincipalType.BOTH:
# # Get player IDs for both event_user and event_manager
# from .models import IAmPrincipal # Assuming your IAmPrincipal model is in the same app
# event_user_principals = IAmPrincipal.objects.filter(iam_principal_type__name=PrincipalType.EVENT_USER)
# event_manager_principals = IAmPrincipal.objects.filter(iam_principal_type__name=PrincipalType.EVENT_MANAGER)
# for principal in event_user_principals:
# # Assuming you have a field in IAmPrincipal that stores the player ID (e.g., 'one_signal_player_id')
# player_ids.append(principal.one_signal_player_id)
# for principal in event_manager_principals:
# player_ids.append(principal.one_signal_player_id)
# else:
# # Handle filtering for EVENT_USER or EVENT_MANAGER as needed (similar logic as above)
# # Construct the notification payload
# data = {
# "app_id": onesignal_app_id,
# "headings": {"en": notification.title},
# "contents": {"en": notification.message},
# "include_player_ids": player_ids, # Include the filtered player IDs
# # Add other optional notification data according to OneSignal documentation
# }
# if notification.banner_image:
# # Include image URL if provided (requires additional OneSignal configuration)
# data["large_icon"] = notification.banner_image.url # Assuming you have a URL property for the image field
# # Send the notification request to OneSignal
# response = requests.post("https://onesignal.com/api/v1/notifications", headers=headers, json=data)
# if response.status_code == 200:
# print("Notification sent successfully!")
# else:
# print(f"Error sending notification: {response.text}")
import json
import requests
from onesignal_sdk.client import Client as OneSignalClient
from django.conf import settings
from .models import IAmPrincipalNotificationSettings, NotificationSettings, IAmPrincipal
from .models import IAmPrincipalNotificationSettings, IAmPrincipal, PrincipalType
def send_notification(notification_type, title, message, image_url=None):
def onesignal_send_notification(
title, message, image_url="http://127.0.0.1:8000/", eligible_principals=None
):
onesignal_app_id = settings.ONE_SIGNAL_APP_ID
onesignal_rest_api_key = settings.ONE_SIGNAL_API_KEY
headers = {
"Content-Type": "application/json; charset=utf-8",
"Authorization": f"Basic {onesignal_rest_api_key}",
}
if not eligible_principals:
return None # Or handle this scenario as needed
# Extract OneSignal player IDs
# player_ids = eligible_principals.values_list("player_id", flat=True)
# Construct the notification payload
data = {
"app_id": onesignal_app_id,
"headings": {"en": title},
"contents": {"en": message},
"include_player_ids": eligible_principals, # Include the filtered player IDs
# Add other optional notification data according to OneSignal documentation
}
if image_url:
# Include image URL if provided (requires additional OneSignal configuration)
data["large_icon"] = (
str(image_url.url)
)
# data = json.dumps(data)
print("Data: ", data)
# Send the notification request to OneSignal
response = requests.post(
"https://onesignal.com/api/v1/notifications", headers=headers, json=data
)
if response.status_code == 200:
print("Notification sent successfully!")
return True # Indicate success
else:
print(f"Error sending notification: {response.text}")
return False # Indicate failure
def send_notification(title, message, image_url=None, eligible_principals=None):
# Initialize OneSignal client
onesignal_client = OneSignalClient(
app_id=settings.ONE_SIGNAL_APP_ID, rest_api_key=settings.ONE_SIGNAL_API_KEY
)
# Find all users who have enabled this type of notification
notification_setting = NotificationSettings.objects.filter(
name=notification_type
).first()
if not notification_setting:
print("Notification type does not exist.")
return
user_ids = IAmPrincipalNotificationSettings.objects.filter(
notification_setting=notification_setting, is_enabled=True
).values_list("principal__id", flat=True)
principals = (
IAmPrincipal.objects.filter(id__in=user_ids)
.exclude(player_id__isnull=True)
.exclude(player_id__exact="")
)
if not eligible_principals:
return None # Or handle this scenario as needed
# Extract OneSignal player IDs
player_ids = principals.values_list("player_id", flat=True)
player_ids = eligible_principals.values_list("player_id", flat=True)
# Prepare notification payload
notification_payload = {
@@ -99,3 +80,45 @@ def send_notification(notification_type, title, message, image_url=None):
print(response.status_code, response.body)
return response
def get_eligible_principals_for_notification(push_notification):
principal_type = push_notification.principal_type
notification_category = push_notification.notification_category
if principal_type == PrincipalType.BOTH:
# If BOTH is selected, fetch users categorized under both EVENT_USER and EVENT_MANAGER
event_user_principals = IAmPrincipal.objects.filter(
principal_type__name=PrincipalType.EVENT_USER,
notifications_principal__notification_category=notification_category,
notifications_principal__is_enabled=True,
)
event_manager_principals = IAmPrincipal.objects.filter(
principal_type__name=PrincipalType.EVENT_MANAGER,
notifications_principal__notification_category=notification_category,
notifications_principal__is_enabled=True,
)
# Combine the QuerySets. Use | operator for OR query (union) and distinct() to avoid duplicates.
eligible_principals = (
event_user_principals | event_manager_principals
).distinct()
elif principal_type == PrincipalType.EVENT_USER:
# Fetch only EVENT_USER principals
eligible_principals = IAmPrincipal.objects.filter(
principal_type__name=PrincipalType.EVENT_USER,
notifications_principal__notification_category=notification_category,
notifications_principal__is_enabled=True,
)
elif principal_type == PrincipalType.EVENT_MANAGER:
# Fetch only EVENT_MANAGER principals
eligible_principals = IAmPrincipal.objects.filter(
principal_type__name=PrincipalType.EVENT_MANAGER,
notifications_principal__notification_category=notification_category,
notifications_principal__is_enabled=True,
)
return eligible_principals

View File

@@ -1,12 +1,18 @@
import json
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse_lazy
from django.views import generic
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib import messages
from django.core.serializers import serialize
from accounts import resource_action
from goodtimes import constants
from manage_notifications.forms import PushNotificationForm
from manage_notifications.models import PushNotification
from manage_notifications.utils import (
get_eligible_principals_for_notification,
onesignal_send_notification,
)
# Create your views here.
@@ -68,14 +74,43 @@ class PushNotificationsCreateOrUpdateView(LoginRequiredMixin, generic.View):
print(form.errors)
context = self.get_context_data(form=form)
return render(request, self.template_name, context=context)
push_notification = form.instance
eligible_principals = get_eligible_principals_for_notification(
push_notification
)
print("eligible_principals: ", eligible_principals)
eligible_principals_json = serialize('json', eligible_principals)
eligible_principals_list = json.loads(eligible_principals_json)
player_ids = [principal['fields']['player_id'] for principal in eligible_principals_list]
# Send notification
title = push_notification.title
message = push_notification.message
image_url = push_notification.banner_image
onesignal_send_notification(title, message, image_url, player_ids)
print(onesignal_send_notification)
# if onesignal_send_notification(
# push_notification.title,
# push_notification.message,
# None,
# eligible_principals,
# ):
# form.save()
# messages.success(request, self.get_success_message())
# return redirect(self.success_url)
# else:
# messages.error(request, "Failed to send notification. Form not saved.")
# context = self.get_context_data(form=form)
# return render(request, self.template_name, context=context)
form.save()
messages.success(self.request, self.get_success_message())
messages.success(request, self.get_success_message())
return redirect(self.success_url)
class PushNotificationView(LoginRequiredMixin, generic.ListView):
page_name = resource_action.RESOURCE_MANAGE_EVENTS
resource = resource_action.RESOURCE_MANAGE_EVENTS
page_name = resource_action.RESOURCE_MANAGE_NOTIFICATIONS
resource = resource_action.RESOURCE_MANAGE_NOTIFICATIONS
action = resource_action.ACTION_READ
model = PushNotification
template_name = "manage_notifications/notification_list.html"
@@ -87,4 +122,4 @@ class PushNotificationView(LoginRequiredMixin, generic.ListView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context["page_name"] = self.page_name
return context
return context

View File

@@ -415,7 +415,7 @@ def create_checkout_session(request):
transaction = Transaction.objects.create(
principal=request.user,
principal_subscription=None, # Since the subscription is not created yet
transaction_type=TransactionType.DEPOSIT, # or PAYMENT, as applicable
transaction_type=TransactionType.PAYMENT, # or PAYMENT, as applicable
payment_method=PaymentMethod.CARD, # Assuming CARD for this example
transaction_status=TransactionStatus.INITIATE,
amount=subscription.amount, # Fetching amount from the Subscription object

View File

@@ -0,0 +1,39 @@
# Generated by Django 5.0.2 on 2024-03-13 19:07
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("manage_wallets", "0004_alter_wallet_coins"),
]
operations = [
migrations.AddField(
model_name="transaction",
name="coin",
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name="transaction",
name="payment_method",
field=models.CharField(
choices=[("card", "Card"), ("upi", "UPI"), ("coin", "Coin")],
max_length=10,
),
),
migrations.AlterField(
model_name="transaction",
name="transaction_type",
field=models.CharField(
choices=[
("payment", "Payment"),
("deposit", "Deposit"),
("withdraw", "Withdraw"),
("credit", "Credit"),
],
max_length=10,
),
),
]

View File

@@ -34,6 +34,7 @@ class TransactionType(models.TextChoices):
PAYMENT = "payment", "Payment"
DEPOSIT = "deposit", "Deposit"
WITHDRAW = "withdraw", "Withdraw"
CREDIT = "credit", "Credit"
class TransactionStatus(models.TextChoices):
@@ -45,6 +46,7 @@ class TransactionStatus(models.TextChoices):
class PaymentMethod(models.TextChoices):
CARD = "card", "Card"
UPI = "upi", "UPI"
COIN = "coin", "Coin"
class Transaction(BaseModel):
@@ -70,6 +72,7 @@ class Transaction(BaseModel):
default=TransactionStatus.INITIATE,
)
amount = models.DecimalField(max_digits=14, decimal_places=2, default=0.00)
coin = models.IntegerField(default=0)
comment = models.CharField(max_length=200, null=True, blank=True)
order_id = models.CharField(unique=True, max_length=255, null=True, blank=True)
product_id = models.CharField(unique=True, max_length=255, null=True, blank=True)

View File

@@ -173,15 +173,15 @@
</div>
</a>
</li>
<li class="menu">
<!-- <li class="menu">
<a href="./app-calendar.html" aria-expanded="false" class="dropdown-toggle">
<div class="">
<span class="material-symbols-outlined">account_circle</span>
<span>My Profile</span>
</div>
</a>
</li>
<li class="menu">
</li> -->
<li class="menu {% if page_name == resource_context.RESOURCE_MANAGE_NOTIFICATIONS %}active{% endif %}">
<a href="{% url 'manage_notifications:notification_list'%}" aria-expanded="false" class="dropdown-toggle">
<div class="">
<span class="material-symbols-outlined">notifications</span>

View File

@@ -16,7 +16,7 @@
<div class="col-lg-12">
<div class="row mb-2">
<div class="col">
<h3>{{operation}} {{page_name}}</h3>
<h3>Send Notifications</h3>
</div>
<div class="col text-end">
<button class="btn btn-dark mb-2 me-4" onclick="history.back()">
@@ -30,7 +30,7 @@
<div class="statbox widget box box-shadow">
<div class="widget-content widget-content-area">
<form method="POST" novalidate>
<form method="POST" enctype="multipart/form-data">
{% csrf_token %}
{% include 'includes/dynamic_template_form.html' with form=form %}
<div class="mt-4 mb-0">

View File

@@ -12,7 +12,7 @@
<div class="col-lg-12">
<div class="row">
<div class="col-sm-6">
<h3>Manage Plans</h3>
<h3>Manage Notifications</h3>
</div>
<div class="col-sm-6 text-md-end">
<!--
@@ -71,7 +71,7 @@
</td>
<td class="text-center">
<ul class="table-controls">
<li><a href="{% url 'manage_subscriptions:notification_edit' data_obj.id %}" class="bs-tooltip"
<li><a href="{% url 'manage_notifications:notification_edit' data_obj.id %}" class="bs-tooltip"
data-bs-toggle="tooltip" data-bs-placement="top" title=""
data-original-title="Edit" data-bs-original-title="Edit"
aria-label="Edit"><svg xmlns="http://www.w3.org/2000/svg"