- Refactored monetary fields across models to use `DecimalField` for improved precision. - Implemented two-factor authentication (2FA) for admin logins with OTP codes. - Added ability to generate admin OTP via management commands. - Updated Docker Compose override for dev-specific port bindings. - Included template for 2FA OTP verification to enhance security. Additional changes: - Upgraded and downgraded various dependencies (e.g., django-celery-beat and yarl). - Replaced float-based calculations with decimal for consistent rounding behavior. - Improved admin user management commands for activation and OTP generation.
427 lines
13 KiB
Python
427 lines
13 KiB
Python
from typing import Any
|
|
|
|
from django.contrib import admin, messages
|
|
from django.contrib.admin import register
|
|
from django.contrib.auth.admin import (
|
|
GroupAdmin as BaseGroupAdmin,
|
|
)
|
|
from django.contrib.auth.admin import (
|
|
UserAdmin as BaseUserAdmin,
|
|
)
|
|
from django.contrib.auth.models import Group as BaseGroup
|
|
from django.contrib.auth.models import Permission
|
|
from django.core.exceptions import PermissionDenied
|
|
from django.db.models import Prefetch, QuerySet
|
|
from django.http import HttpRequest
|
|
from django.utils import timezone
|
|
from django.utils.translation import gettext_lazy as _
|
|
from rest_framework_simplejwt.token_blacklist.admin import (
|
|
BlacklistedTokenAdmin as BaseBlacklistedTokenAdmin,
|
|
)
|
|
from rest_framework_simplejwt.token_blacklist.admin import (
|
|
OutstandingTokenAdmin as BaseOutstandingTokenAdmin,
|
|
)
|
|
from rest_framework_simplejwt.token_blacklist.models import (
|
|
BlacklistedToken as BaseBlacklistedToken,
|
|
)
|
|
from rest_framework_simplejwt.token_blacklist.models import (
|
|
OutstandingToken as BaseOutstandingToken,
|
|
)
|
|
from unfold.admin import ModelAdmin, TabularInline
|
|
from unfold.forms import AdminPasswordChangeForm, UserCreationForm
|
|
from unfold_markdown import MarkdownWidget
|
|
|
|
from engine.core.admin import ActivationActionsMixin
|
|
from engine.core.models import Order
|
|
from engine.payments.models import Balance
|
|
from engine.vibes_auth.emailing.choices import CampaignStatus
|
|
from engine.vibes_auth.emailing.models import (
|
|
CampaignRecipient,
|
|
EmailCampaign,
|
|
EmailTemplate,
|
|
)
|
|
from engine.vibes_auth.emailing.tasks import (
|
|
prepare_campaign_recipients,
|
|
send_campaign_email_batch,
|
|
)
|
|
from engine.vibes_auth.forms import UserForm
|
|
from engine.vibes_auth.models import (
|
|
AdminOTPCode,
|
|
BlacklistedToken,
|
|
ChatMessage,
|
|
ChatThread,
|
|
Group,
|
|
OutstandingToken,
|
|
ThreadStatus,
|
|
User,
|
|
)
|
|
|
|
|
|
class BalanceInline(TabularInline):
|
|
model = Balance
|
|
can_delete = False
|
|
extra = 0
|
|
verbose_name = _("balance")
|
|
verbose_name_plural = _("balance")
|
|
is_navtab = True
|
|
icon = "fa-solid fa-wallet"
|
|
|
|
|
|
class OrderInline(TabularInline):
|
|
model = Order
|
|
extra = 0
|
|
verbose_name = _("order")
|
|
verbose_name_plural = _("orders")
|
|
is_navtab = True
|
|
icon = "fa-solid fa-cart-shopping"
|
|
|
|
|
|
class UserAdmin(ActivationActionsMixin, BaseUserAdmin, ModelAdmin):
|
|
inlines = (BalanceInline, OrderInline)
|
|
fieldsets = (
|
|
(None, {"fields": ("email", "password")}),
|
|
(
|
|
_("personal info"),
|
|
{"fields": ("first_name", "last_name", "phone_number", "avatar")},
|
|
),
|
|
(
|
|
_("permissions"),
|
|
{
|
|
"fields": (
|
|
"is_active",
|
|
"is_verified",
|
|
"is_subscribed",
|
|
"is_staff",
|
|
"is_superuser",
|
|
"groups",
|
|
"user_permissions",
|
|
)
|
|
},
|
|
),
|
|
(_("important dates"), {"fields": ("last_login", "date_joined")}),
|
|
(_("additional info"), {"fields": ("language", "attributes")}),
|
|
)
|
|
add_fieldsets = (
|
|
(
|
|
None,
|
|
{
|
|
"classes": ("wide",),
|
|
"fields": ("email", "password1", "password2"),
|
|
},
|
|
),
|
|
)
|
|
list_display = ("email", "phone_number", "is_verified", "is_active", "is_staff")
|
|
# phone_number is encrypted — DB-level search is not possible for it
|
|
search_fields = ("email",)
|
|
list_filter = (
|
|
"is_verified",
|
|
"is_active",
|
|
"is_staff",
|
|
"is_superuser",
|
|
"is_subscribed",
|
|
)
|
|
ordering = ("email",)
|
|
readonly_fields = ("password",)
|
|
form = UserForm
|
|
add_form = UserCreationForm
|
|
change_password_form = AdminPasswordChangeForm
|
|
|
|
def get_queryset(self, request: HttpRequest) -> QuerySet[User]:
|
|
qs = super().get_queryset(request)
|
|
return qs.prefetch_related(
|
|
"groups", "payments_balance", "orders"
|
|
).prefetch_related(
|
|
Prefetch(
|
|
"user_permissions",
|
|
queryset=Permission.objects.select_related("content_type"),
|
|
)
|
|
)
|
|
|
|
def save_model( # ty: ignore[invalid-method-override]
|
|
self, request: HttpRequest, obj: Any, form: UserForm, change: Any
|
|
) -> None:
|
|
if form.cleaned_data.get("attributes") is None:
|
|
obj.attributes = None
|
|
if (
|
|
form.cleaned_data.get("is_superuser", False)
|
|
and not request.user.is_superuser # ty: ignore[possibly-missing-attribute]
|
|
):
|
|
raise PermissionDenied(_("You cannot jump over your head!"))
|
|
super().save_model(request, obj, form, change) # ty: ignore[invalid-argument-type]
|
|
|
|
|
|
# noinspection PyUnusedLocal
|
|
@register(ChatThread)
|
|
class ChatThreadAdmin(ModelAdmin):
|
|
list_display = (
|
|
"uuid",
|
|
"user",
|
|
"email",
|
|
"assigned_to",
|
|
"status",
|
|
"last_message_at",
|
|
"is_active",
|
|
"created",
|
|
"modified",
|
|
)
|
|
list_filter = (
|
|
"status",
|
|
"is_active",
|
|
("assigned_to", admin.EmptyFieldListFilter),
|
|
)
|
|
search_fields = ("uuid", "email", "user__email", "user__username")
|
|
autocomplete_fields = ("user", "assigned_to")
|
|
actions = (
|
|
"close_threads",
|
|
"open_threads",
|
|
"delete_selected",
|
|
)
|
|
readonly_fields = ("created", "modified")
|
|
|
|
@admin.action(description=_("Close selected threads"))
|
|
def close_threads(self, request, queryset):
|
|
queryset.update(status=ThreadStatus.CLOSED)
|
|
|
|
@admin.action(description=_("Open selected threads"))
|
|
def open_threads(self, request, queryset):
|
|
queryset.update(status=ThreadStatus.OPEN)
|
|
|
|
|
|
@register(ChatMessage)
|
|
class ChatMessageAdmin(admin.ModelAdmin):
|
|
list_display = ("uuid", "thread", "sender_type", "sender_user", "sent_at")
|
|
list_filter = ("sender_type",)
|
|
search_fields = ("uuid", "thread__uuid", "sender_user__email")
|
|
autocomplete_fields = ("thread", "sender_user")
|
|
readonly_fields = ("created", "modified")
|
|
|
|
|
|
@register(EmailTemplate)
|
|
class EmailTemplateAdmin(ActivationActionsMixin, ModelAdmin):
|
|
list_display = ("name", "slug", "subject", "is_active", "modified")
|
|
list_filter = ("is_active",)
|
|
search_fields = ("name", "slug", "subject")
|
|
prepopulated_fields = {"slug": ("name",)}
|
|
readonly_fields = ("uuid", "created", "modified")
|
|
|
|
fieldsets = (
|
|
(None, {"fields": ("name", "slug", "subject")}),
|
|
(_("Content"), {"fields": ("content", "plain_content")}),
|
|
(
|
|
_("Documentation"),
|
|
{
|
|
"fields": ("available_variables",),
|
|
"classes": ("collapse",),
|
|
},
|
|
),
|
|
(_("Metadata"), {"fields": ("uuid", "is_active", "created", "modified")}),
|
|
)
|
|
|
|
def formfield_for_dbfield(self, db_field, request, **kwargs):
|
|
if db_field.name == "content":
|
|
kwargs["widget"] = MarkdownWidget
|
|
return super().formfield_for_dbfield(db_field, request, **kwargs)
|
|
|
|
|
|
class CampaignRecipientInline(TabularInline):
|
|
model = CampaignRecipient
|
|
extra = 0
|
|
readonly_fields = (
|
|
"user",
|
|
"status",
|
|
"sent_at",
|
|
"opened_at",
|
|
"clicked_at",
|
|
"error_message",
|
|
)
|
|
can_delete = False
|
|
max_num = 0 # Prevent adding new recipients via inline
|
|
|
|
def has_add_permission(self, request, obj=None):
|
|
return False
|
|
|
|
|
|
@register(EmailCampaign)
|
|
class EmailCampaignAdmin(ActivationActionsMixin, ModelAdmin):
|
|
list_display = (
|
|
"name",
|
|
"template",
|
|
"status",
|
|
"scheduled_at",
|
|
"sent_at",
|
|
"stats_display",
|
|
"is_active",
|
|
)
|
|
list_filter = ("status", "is_active", "template")
|
|
search_fields = ("name",)
|
|
readonly_fields = (
|
|
"uuid",
|
|
"created",
|
|
"modified",
|
|
"sent_at",
|
|
"total_recipients",
|
|
"sent_count",
|
|
"failed_count",
|
|
"opened_count",
|
|
"clicked_count",
|
|
)
|
|
autocomplete_fields = ("template",)
|
|
inlines = (CampaignRecipientInline,)
|
|
actions = [
|
|
"delete_selected",
|
|
"activate_selected",
|
|
"deactivate_selected",
|
|
"prepare_recipients",
|
|
"send_campaign_now",
|
|
"cancel_campaign",
|
|
]
|
|
|
|
fieldsets = (
|
|
(None, {"fields": ("name", "template", "status")}),
|
|
(_("Scheduling"), {"fields": ("scheduled_at", "sent_at")}),
|
|
(
|
|
_("Statistics"),
|
|
{
|
|
"fields": (
|
|
"total_recipients",
|
|
"sent_count",
|
|
"failed_count",
|
|
"opened_count",
|
|
"clicked_count",
|
|
)
|
|
},
|
|
),
|
|
(_("Metadata"), {"fields": ("uuid", "is_active", "created", "modified")}),
|
|
)
|
|
|
|
@admin.display(description=_("stats"))
|
|
def stats_display(self, obj: EmailCampaign) -> str:
|
|
return (
|
|
f"{obj.sent_count}/{obj.total_recipients} sent, {obj.opened_count} opened"
|
|
)
|
|
|
|
@admin.action(description=_("Prepare recipients (subscribed users)"))
|
|
def prepare_recipients(
|
|
self, request: HttpRequest, queryset: QuerySet[EmailCampaign]
|
|
) -> None:
|
|
for campaign in queryset:
|
|
if campaign.status != CampaignStatus.DRAFT:
|
|
self.message_user(
|
|
request,
|
|
_("Campaign '{}' is not in draft status.").format(campaign.name),
|
|
level=messages.WARNING,
|
|
)
|
|
continue
|
|
prepare_campaign_recipients.delay(str(campaign.uuid))
|
|
self.message_user(
|
|
request,
|
|
_("Preparing recipients for campaign '{}'.").format(campaign.name),
|
|
level=messages.SUCCESS,
|
|
)
|
|
|
|
@admin.action(description=_("Send campaign now"))
|
|
def send_campaign_now(
|
|
self, request: HttpRequest, queryset: QuerySet[EmailCampaign]
|
|
) -> None:
|
|
for campaign in queryset:
|
|
if campaign.status not in (CampaignStatus.DRAFT, CampaignStatus.SCHEDULED):
|
|
self.message_user(
|
|
request,
|
|
_("Campaign '{}' cannot be sent (status: {}).").format(
|
|
campaign.name, campaign.status
|
|
),
|
|
level=messages.WARNING,
|
|
)
|
|
continue
|
|
if campaign.total_recipients == 0:
|
|
self.message_user(
|
|
request,
|
|
_(
|
|
"Campaign '{}' has no recipients. Prepare recipients first."
|
|
).format(campaign.name),
|
|
level=messages.WARNING,
|
|
)
|
|
continue
|
|
|
|
campaign.status = CampaignStatus.SENDING
|
|
campaign.save(update_fields=["status", "modified"])
|
|
send_campaign_email_batch.delay(str(campaign.uuid))
|
|
self.message_user(
|
|
request,
|
|
_("Started sending campaign '{}'.").format(campaign.name),
|
|
level=messages.SUCCESS,
|
|
)
|
|
|
|
@admin.action(description=_("Cancel campaign"))
|
|
def cancel_campaign(
|
|
self, request: HttpRequest, queryset: QuerySet[EmailCampaign]
|
|
) -> None:
|
|
updated = queryset.filter(
|
|
status__in=(CampaignStatus.DRAFT, CampaignStatus.SCHEDULED)
|
|
).update(status=CampaignStatus.CANCELLED, modified=timezone.now())
|
|
self.message_user(
|
|
request,
|
|
_("{} campaign(s) cancelled.").format(updated),
|
|
level=messages.SUCCESS,
|
|
)
|
|
|
|
|
|
@register(CampaignRecipient)
|
|
class CampaignRecipientAdmin(ModelAdmin):
|
|
list_display = (
|
|
"campaign",
|
|
"user",
|
|
"status",
|
|
"sent_at",
|
|
"opened_at",
|
|
"clicked_at",
|
|
)
|
|
list_filter = ("status", "campaign")
|
|
search_fields = ("user__email", "campaign__name")
|
|
readonly_fields = (
|
|
"uuid",
|
|
"campaign",
|
|
"user",
|
|
"tracking_id",
|
|
"created",
|
|
"modified",
|
|
)
|
|
autocomplete_fields = ("campaign", "user")
|
|
|
|
def has_add_permission(self, request):
|
|
return False # Recipients are added via campaign preparation
|
|
|
|
|
|
class GroupAdmin(BaseGroupAdmin, ModelAdmin):
|
|
pass
|
|
|
|
|
|
class BlacklistedTokenAdmin(BaseBlacklistedTokenAdmin, ModelAdmin):
|
|
pass
|
|
|
|
|
|
class OutstandingTokenAdmin(BaseOutstandingTokenAdmin, ModelAdmin):
|
|
pass
|
|
|
|
|
|
@register(AdminOTPCode)
|
|
class AdminOTPCodeAdmin(ModelAdmin):
|
|
list_display = ("user", "code", "is_used", "created")
|
|
list_filter = ("is_used",)
|
|
search_fields = ("user__email",)
|
|
readonly_fields = ("user", "code", "is_used", "created", "modified")
|
|
|
|
def has_add_permission(self, request):
|
|
return False
|
|
|
|
|
|
admin.site.register(User, UserAdmin)
|
|
|
|
admin.site.unregister(BaseGroup)
|
|
admin.site.register(Group, GroupAdmin)
|
|
|
|
admin.site.unregister(BaseBlacklistedToken)
|
|
admin.site.register(BlacklistedToken, BlacklistedTokenAdmin)
|
|
|
|
admin.site.unregister(BaseOutstandingToken)
|
|
admin.site.register(OutstandingToken, OutstandingTokenAdmin)
|