diff --git a/accounts/activity.py b/accounts/activity.py new file mode 100644 index 0000000..af58731 --- /dev/null +++ b/accounts/activity.py @@ -0,0 +1,42 @@ +""" +«پروندهی کاربر»: ورود، خروج و خریدهای یک کاربر به ترتیبِ زمان، برای ادمین. + +فقط میخواند؛ چیزی جدا ذخیره نمیکند، پس با جدولهای اصلی (LoginEvent، Order، StatusChangeLog) هرگز +ناهماهنگ نمیشود. تصمیمِ ادغامنکردنِ اینها در یک جدول: ستونهایشان فرق دارد. +آزموده نشده: کاربری با دهها هزار ردیف (همهی ردیفها در حافظه میآیند). +""" +from django.utils import timezone + +from payment.models import Order +from statuslog.models import StatusChangeLog + +from .models import LoginEvent, User + +_STATUS_FA = dict(Order.STATUS_CHOICES) +_EVENT_FA = dict(LoginEvent.EVENT_CHOICES) +COLUMNS = ['زمان', 'نوع', 'شرح', 'مبلغ', 'آیپی'] + + +def user_activity(user: User) -> list[dict]: + rows = [{'at': user.created_at, 'kind': 'ثبتنام', 'detail': '', 'amount': '', 'ip': ''}] + for e in LoginEvent.objects.filter(user_id=user.id): + rows.append({'at': e.created_at, 'kind': _EVENT_FA.get(e.event, e.event), + 'detail': e.user_agent[:80], 'amount': '', 'ip': e.ip or ''}) + orders = list(Order.objects.filter(user_id=user.id)) + for o in orders: + rows.append({ + 'at': o.created_at, 'kind': 'سفارش', + 'detail': f'#{o.id} {o.plan_title or "بدون پلن"} ({o.get_provider_display()})', + 'amount': f'{o.amount} {o.currency}', 'ip': '', + }) + for c in StatusChangeLog.objects.filter(label='payment.Order', object_id__in=[o.id for o in orders]): + rows.append({ + 'at': c.created_at, 'kind': 'تغییر وضعیت سفارش', + 'detail': f'#{c.object_id}: {_STATUS_FA.get(c.old_status, c.old_status or "—")} ← ' + f'{_STATUS_FA.get(c.new_status, c.new_status)} ({c.event})', + 'amount': '', 'ip': '', + }) + rows.sort(key=lambda r: r['at']) + for r in rows: + r['at_local'] = timezone.localtime(r['at']).strftime('%Y-%m-%d %H:%M:%S') + return rows diff --git a/accounts/admin.py b/accounts/admin.py index d666ac7..87956f5 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -51,9 +51,52 @@ class SendSMSForm(forms.Form): @admin.register(User) class UserAdmin(admin.ModelAdmin): - list_display = ('id', 'public_id', 'name', 'phone', 'grade_id', 'created_at') + list_display = ('id', 'public_id', 'name', 'phone', 'grade_id', 'created_at', 'activity_link') search_fields = ('name', 'phone') + @admin.display(description='پرونده') + def activity_link(self, obj): + from django.urls import reverse + from django.utils.html import format_html + return format_html('فعالیت', reverse('admin:accounts_user_activity', args=[obj.pk])) + + def get_urls(self): + from django.urls import path + return [ + path('/activity/', self.admin_site.admin_view(self.activity_view), + name='accounts_user_activity'), + ] + super().get_urls() + + def activity_view(self, request, user_id): + """ورود/خروج و خریدهای یک کاربر؛ ?format=csv برای اکسل. فقط با اجازهی دیدنِ کاربر و ورود و سفارش.""" + import csv + from django.http import Http404, HttpResponse + from django.shortcuts import render + from payment.models import Order + from .activity import COLUMNS, user_activity + from .public_id import encode + + perms = ('accounts.view_user', 'accounts.view_loginevent', 'payment.view_order') + if not all(request.user.has_perm(p) for p in perms): + raise PermissionDenied + user = User.objects.filter(pk=user_id).first() + if user is None: + raise Http404 + rows = user_activity(user) + if request.GET.get('format') == 'csv': + response = HttpResponse(content_type='text/csv; charset=utf-8') + response['Content-Disposition'] = f'attachment; filename="user-{user.pk}-activity.csv"' + response.write('\ufeff') # BOM: اکسل بیآن فارسی را خراب میخواند + writer = csv.writer(response) + writer.writerow(COLUMNS) + for r in rows: + writer.writerow([r['at_local'], r['kind'], r['detail'], r['amount'], r['ip']]) + return response + return render(request, 'admin/accounts_user_activity.html', { + **self.admin_site.each_context(request), 'target': user, 'public_id': encode(user.pk), + 'rows': rows, 'columns': COLUMNS, 'title': 'پروندهی کاربر', + }) + @admin.display(description='شناسهی نمایشی') def public_id(self, obj): from .public_id import encode diff --git a/accounts/tests_user_activity.py b/accounts/tests_user_activity.py new file mode 100644 index 0000000..8da46d6 --- /dev/null +++ b/accounts/tests_user_activity.py @@ -0,0 +1,41 @@ +"""پروندهی کاربر در ادمین: ترتیب، CSV، و دسترسی.""" +from django.test import TestCase +from django.utils import timezone + +from accounts.models import LoginEvent, User +from payment.models import Order + + +class UserActivityTests(TestCase): + def setUp(self): + self.user = User.objects.create(phone='09121117001', name='علی') + LoginEvent.objects.create(event='login_ok', user_id=self.user.id, phone=self.user.phone, ip='203.0.113.9') + Order.objects.create(user_id=self.user.id, amount=45000, plan_title='بسته ۲۰', idempotency_key='k1', status='paid') + from django.contrib.auth.models import User as AuthUser + self.admin = AuthUser.objects.create_superuser('boss', 'b@x.ir', 'pw') + self.client.force_login(self.admin) + + def test_page_lists_login_and_order(self): + r = self.client.get(f'/admin/accounts/user/{self.user.id}/activity/') + self.assertEqual(r.status_code, 200) + body = r.content.decode() + self.assertIn('ورود موفق', body) + self.assertIn('بسته ۲۰', body) + self.assertIn('45000 IRT', body) + + def test_csv_has_bom_and_rows(self): + r = self.client.get(f'/admin/accounts/user/{self.user.id}/activity/?format=csv') + text = r.content.decode('utf-8') + self.assertTrue(text.startswith('')) + self.assertIn('203.0.113.9', text) + self.assertEqual(len(text.strip().splitlines()), 4) # سرستون + ثبتنام + ورود + سفارش + + def test_view_only_staff_without_order_permission_is_refused(self): + from django.contrib.auth.models import Permission, User as AuthUser + s = AuthUser.objects.create_user('sup', 's@x.ir', 'pw', is_staff=True) + s.user_permissions.add(Permission.objects.get(codename='view_user', content_type__app_label='accounts')) + self.client.force_login(s) + self.assertEqual(self.client.get(f'/admin/accounts/user/{self.user.id}/activity/').status_code, 403) + + def test_unknown_user_is_404(self): + self.assertEqual(self.client.get('/admin/accounts/user/999999/activity/').status_code, 404) diff --git a/templates/admin/accounts_user_activity.html b/templates/admin/accounts_user_activity.html new file mode 100644 index 0000000..aa36259 --- /dev/null +++ b/templates/admin/accounts_user_activity.html @@ -0,0 +1,16 @@ +{% extends "admin/base_site.html" %} +{% block content %} + + پروندهی کاربر {{ public_id }} — {{ target.phone|default:"بدون شماره" }} + {{ target.name|default:"بینام" }} · {{ rows|length }} رویداد · زمانها به وقتِ تهران + · خروجی CSV (اکسل) + + {% for c in columns %}{{ c }}{% endfor %} + + {% for r in rows %} + {{ r.at_local }}{{ r.kind }}{{ r.detail }}{{ r.amount }}{{ r.ip }} + {% endfor %} + + + +{% endblock %}
{{ target.name|default:"بینام" }} · {{ rows|length }} رویداد · زمانها به وقتِ تهران + · خروجی CSV (اکسل)