From 7a8ddeabed2d91d6c490980dc22e15c674311127 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Mon, 18 May 2026 11:34:07 +0330 Subject: [PATCH] init --- .env.example | 67 + .env.test | 60 + .github/workflows/ci-cd.yml | 236 + .gitignore | 31 + LICENSE | 674 ++ README.md | 39 + alert.rules.yml | 26 + alertmanager.yml | 14 + backend/.coveragerc | 31 + backend/.gitignore | 139 + backend/Dockerfile | 34 + backend/README.md | 38 + backend/api/authentication.py | 41 + backend/api/schemas/__init__.py | 31 + backend/api/schemas/auth.py | 129 + backend/api/schemas/blog.py | 87 + backend/api/schemas/certificates.py | 70 + backend/api/schemas/communications.py | 124 + backend/api/schemas/events.py | 247 + backend/api/schemas/gallery.py | 27 + backend/api/schemas/payments.py | 35 + backend/api/urls.py | 16 + backend/api/views/__init__.py | 9 + backend/api/views/auth.py | 397 + backend/api/views/blog.py | 299 + backend/api/views/certificates.py | 138 + backend/api/views/communications.py | 329 + backend/api/views/events.py | 371 + backend/api/views/gallery.py | 127 + backend/api/views/health.py | 15 + backend/api/views/meta.py | 15 + backend/api/views/payments.py | 240 + backend/blog/admin.py | 159 + backend/blog/apps.py | 5 + backend/blog/fixtures/blog.json | 672 ++ backend/blog/migrations/0001_initial.py | 89 + backend/blog/migrations/0002_initial.py | 78 + backend/blog/migrations/__init__.py | 0 backend/blog/models.py | 137 + backend/blog/resources.py | 32 + backend/celerybeat-schedule | Bin 0 -> 18455 bytes backend/certificates/__init__.py | 1 + backend/certificates/admin.py | 24 + backend/certificates/apps.py | 6 + .../certificates/migrations/0001_initial.py | 80 + backend/certificates/migrations/__init__.py | 1 + backend/certificates/models.py | 316 + backend/communications/admin.py | 122 + backend/communications/apps.py | 7 + .../fixtures/communications.json | 536 ++ .../communications/migrations/0001_initial.py | 78 + .../communications/migrations/0002_initial.py | 37 + backend/communications/migrations/__init__.py | 0 backend/communications/models.py | 142 + backend/communications/push_notifications.py | 194 + backend/communications/resources.py | 56 + backend/communications/tasks.py | 278 + backend/communications/utils.py | 140 + backend/config/__init__.py | 3 + backend/config/asgi.py | 7 + backend/config/services/celery.py | 56 + backend/config/services/location.py | 14 + backend/config/services/notifications.py | 12 + backend/config/services/unfold.py | 94 + backend/config/services/zarinpal.py | 10 + backend/config/settings/base.py | 233 + backend/config/settings/development.py | 18 + backend/config/settings/production.py | 21 + backend/config/settings/test.py | 46 + backend/config/urls.py | 24 + backend/config/wsgi.py | 7 + backend/docker/entrypoint.sh | 26 + backend/docker/nginx.conf | 23 + backend/events/admin.py | 418 + backend/events/admin_forms.py | 25 + backend/events/apps.py | 6 + backend/events/fixtures/events.json | 379 + backend/events/migrations/0001_initial.py | 60 + backend/events/migrations/0002_initial.py | 27 + backend/events/migrations/0003_initial.py | 39 + ...004_event_registration_success_markdown.py | 18 + ...ion_cancellation_email_sent_at_and_more.py | 23 + ...ons_alter_registration_options_and_more.py | 43 + ...ed_at_eventemaillog_is_deleted_and_more.py | 28 + .../0008_alter_eventemaillog_kind.py | 18 + ...9_registration_discount_amount_and_more.py | 30 + .../0010_backfill_registration_discounts.py | 55 + .../0011_eventemaillog_context_hash.py | 22 + .../0012_alter_eventemaillog_kind.py | 18 + backend/events/migrations/__init__.py | 0 backend/events/models.py | 269 + backend/events/resources.py | 86 + backend/events/tasks.py | 584 ++ backend/gallery/admin.py | 89 + backend/gallery/apps.py | 5 + backend/gallery/fixtures/gallery.json | 218 + backend/gallery/migrations/0001_initial.py | 36 + backend/gallery/migrations/0002_initial.py | 23 + backend/gallery/migrations/__init__.py | 0 backend/gallery/models.py | 82 + backend/gallery/resources.py | 17 + backend/gallery/tasks.py | 23 + backend/manage.py | 22 + backend/payments/admin.py | 83 + backend/payments/apps.py | 6 + backend/payments/migrations/0001_initial.py | 64 + backend/payments/migrations/0002_initial.py | 23 + .../migrations/0003_payment_registration.py | 20 + backend/payments/migrations/__init__.py | 0 backend/payments/models.py | 122 + backend/payments/resources.py | 44 + backend/requirements.txt | 71 + backend/static/css/styles.css | 215 + backend/static/img/logo.png | Bin 0 -> 144230 bytes backend/static/js/push-notifications.js | 182 + backend/static/js/scripts.js | 21 + backend/static/js/sw.js | 95 + .../templates/emails/announcement_email.html | 131 + .../templates/emails/event_announcement.html | 19 + .../emails/event_invite_non_registered.html | 65 + .../emails/event_invite_non_registered.txt | 11 + .../event_registration_cancellation.html | 59 + .../event_registration_confirmation.html | 69 + backend/templates/emails/event_reminder.html | 25 + .../emails/newsletter_confirmation.html | 108 + .../emails/password_reset_email.html | 132 + .../templates/emails/skyroom_credentials.html | 32 + .../templates/emails/verification_email.html | 61 + .../emails/verification_success.html | 106 + .../templates/forms/admin_announcement.html | 26 + backend/tests/__init__.py | 0 backend/tests/integration/__init__.py | 0 backend/tests/integration/test_events.py | 540 ++ backend/tests/integration/test_payments.py | 282 + backend/tests/integration/test_users.py | 724 ++ backend/tests/unit/__init__.py | 0 backend/tests/unit/test_events.py | 1197 +++ backend/tests/unit/test_payments.py | 194 + backend/tests/unit/test_users.py | 400 + backend/users/admin.py | 122 + backend/users/apps.py | 8 + backend/users/fixtures/agile.json | 48 + backend/users/fixtures/users.json | 244 + backend/users/migrations/0001_initial.py | 60 + .../migrations/0002_alter_user_university.py | 18 + .../migrations/0003_alter_user_university.py | 18 + .../0004_major_university_models.py | 116 + .../0005_populate_major_university.py | 60 + .../migrations/0006_remove_legacy_fields.py | 19 + backend/users/migrations/__init__.py | 0 backend/users/models.py | 112 + backend/users/resources.py | 29 + backend/users/signals.py | 27 + backend/users/tasks.py | 99 + backend/utils/admin.py | 85 + backend/utils/choices.py | 293 + backend/utils/models.py | 57 + backend/utils/templatetags/jalali.py | 23 + docker-compose.yml | 252 + frontend/.gitignore | 24 + frontend/Dockerfile | 32 + frontend/README.md | 36 + frontend/bun.lockb | Bin 0 -> 197327 bytes frontend/components.json | 20 + frontend/eslint.config.js | 26 + frontend/index.html | 22 + frontend/nginx.conf | 33 + frontend/package-lock.json | 8556 +++++++++++++++++ frontend/package.json | 91 + frontend/postcss.config.js | 6 + frontend/public/enamad.png | Bin 0 -> 4239 bytes frontend/public/favicon.ico | Bin 0 -> 144230 bytes frontend/public/placeholder.svg | 1 + frontend/public/robots.txt | 14 + frontend/src/App.css | 42 + frontend/src/App.tsx | 72 + frontend/src/components/CouponDialogFa.tsx | 136 + frontend/src/components/Footer.tsx | 157 + frontend/src/components/Layout.tsx | 15 + frontend/src/components/Markdown.tsx | 110 + frontend/src/components/ModeToggle.tsx | 40 + frontend/src/components/Navbar.tsx | 205 + frontend/src/components/PaymentResult.tsx | 48 + frontend/src/components/ScrollToTop.tsx | 36 + .../src/components/SearchableCombobox.tsx | 92 + frontend/src/components/ThemeProvider.tsx | 56 + frontend/src/components/ui/accordion.tsx | 52 + frontend/src/components/ui/alert-dialog.tsx | 104 + frontend/src/components/ui/alert.tsx | 43 + frontend/src/components/ui/aspect-ratio.tsx | 5 + frontend/src/components/ui/avatar.tsx | 38 + frontend/src/components/ui/badge.tsx | 30 + frontend/src/components/ui/breadcrumb.tsx | 90 + frontend/src/components/ui/button.tsx | 48 + frontend/src/components/ui/calendar.tsx | 54 + frontend/src/components/ui/card.tsx | 43 + frontend/src/components/ui/carousel.tsx | 224 + frontend/src/components/ui/chart.tsx | 303 + frontend/src/components/ui/checkbox.tsx | 26 + frontend/src/components/ui/collapsible.tsx | 9 + frontend/src/components/ui/command.tsx | 132 + frontend/src/components/ui/context-menu.tsx | 178 + frontend/src/components/ui/dialog.tsx | 95 + frontend/src/components/ui/drawer.tsx | 87 + frontend/src/components/ui/dropdown-menu.tsx | 179 + frontend/src/components/ui/form.tsx | 130 + frontend/src/components/ui/hover-card.tsx | 27 + frontend/src/components/ui/input-otp.tsx | 61 + frontend/src/components/ui/input.tsx | 22 + frontend/src/components/ui/label.tsx | 17 + frontend/src/components/ui/menubar.tsx | 207 + .../src/components/ui/navigation-menu.tsx | 121 + frontend/src/components/ui/pagination.tsx | 81 + frontend/src/components/ui/popover.tsx | 29 + frontend/src/components/ui/progress.tsx | 23 + frontend/src/components/ui/radio-group.tsx | 36 + frontend/src/components/ui/resizable.tsx | 37 + frontend/src/components/ui/scroll-area.tsx | 38 + frontend/src/components/ui/select.tsx | 143 + frontend/src/components/ui/separator.tsx | 20 + frontend/src/components/ui/sheet.tsx | 107 + frontend/src/components/ui/sidebar.tsx | 638 ++ frontend/src/components/ui/skeleton.tsx | 7 + frontend/src/components/ui/slider.tsx | 23 + frontend/src/components/ui/sonner.tsx | 28 + frontend/src/components/ui/switch.tsx | 27 + frontend/src/components/ui/table.tsx | 72 + frontend/src/components/ui/tabs.tsx | 58 + frontend/src/components/ui/textarea.tsx | 21 + frontend/src/components/ui/toast.tsx | 112 + frontend/src/components/ui/toaster.tsx | 24 + frontend/src/components/ui/toggle-group.tsx | 49 + frontend/src/components/ui/toggle.tsx | 38 + frontend/src/components/ui/tooltip.tsx | 28 + frontend/src/components/ui/use-toast.ts | 3 + frontend/src/contexts/AuthContext.tsx | 74 + frontend/src/hooks/use-mobile.tsx | 19 + frontend/src/hooks/use-toast.ts | 186 + frontend/src/index.css | 109 + frontend/src/lib/api.ts | 661 ++ frontend/src/lib/swagger.json | 1 + frontend/src/lib/types.ts | 354 + frontend/src/lib/utils.ts | 99 + frontend/src/main.tsx | 11 + frontend/src/pages/AboutUs.tsx | 384 + frontend/src/pages/AdminEventDetail.tsx | 214 + frontend/src/pages/AdminEventEdit.tsx | 270 + frontend/src/pages/AdminEvents.tsx | 270 + frontend/src/pages/AdminLayout.tsx | 60 + frontend/src/pages/AdminUsers.tsx | 273 + frontend/src/pages/Auth.tsx | 549 ++ frontend/src/pages/Blog.tsx | 94 + frontend/src/pages/EventDetail.tsx | 548 ++ frontend/src/pages/EventFreeSuccessPage.tsx | 154 + frontend/src/pages/Events.tsx | 229 + frontend/src/pages/Home.tsx | 130 + frontend/src/pages/Index.tsx | 14 + frontend/src/pages/Logout.tsx | 34 + frontend/src/pages/NotFound.tsx | 25 + frontend/src/pages/PaymentResult.tsx | 275 + frontend/src/pages/Profile.tsx | 606 ++ frontend/src/pages/ResetPasswordConfirm.tsx | 75 + frontend/src/pages/ResetPasswordRequest.tsx | 58 + frontend/src/pages/VerifyEmail.tsx | 120 + frontend/src/vite-env.d.ts | 1 + frontend/tailwind.config.ts | 95 + frontend/tsconfig.app.json | 30 + frontend/tsconfig.json | 16 + frontend/tsconfig.node.json | 22 + frontend/vite.config.ts | 18 + grafana-datasources.yml | 7 + nginx-static.conf | 26 + prometheus.yml | 42 + screenshots/about.png | Bin 0 -> 111876 bytes screenshots/admin.png | Bin 0 -> 139739 bytes screenshots/event-detail.png | Bin 0 -> 493737 bytes screenshots/events.png | Bin 0 -> 661678 bytes screenshots/home.png | Bin 0 -> 124058 bytes screenshots/profile.png | Bin 0 -> 121899 bytes 279 files changed, 37390 insertions(+) create mode 100644 .env.example create mode 100644 .env.test create mode 100644 .github/workflows/ci-cd.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 alert.rules.yml create mode 100644 alertmanager.yml create mode 100644 backend/.coveragerc create mode 100644 backend/.gitignore create mode 100644 backend/Dockerfile create mode 100644 backend/README.md create mode 100644 backend/api/authentication.py create mode 100644 backend/api/schemas/__init__.py create mode 100644 backend/api/schemas/auth.py create mode 100644 backend/api/schemas/blog.py create mode 100644 backend/api/schemas/certificates.py create mode 100644 backend/api/schemas/communications.py create mode 100644 backend/api/schemas/events.py create mode 100644 backend/api/schemas/gallery.py create mode 100644 backend/api/schemas/payments.py create mode 100644 backend/api/urls.py create mode 100644 backend/api/views/__init__.py create mode 100644 backend/api/views/auth.py create mode 100644 backend/api/views/blog.py create mode 100644 backend/api/views/certificates.py create mode 100644 backend/api/views/communications.py create mode 100644 backend/api/views/events.py create mode 100644 backend/api/views/gallery.py create mode 100644 backend/api/views/health.py create mode 100644 backend/api/views/meta.py create mode 100644 backend/api/views/payments.py create mode 100644 backend/blog/admin.py create mode 100644 backend/blog/apps.py create mode 100644 backend/blog/fixtures/blog.json create mode 100644 backend/blog/migrations/0001_initial.py create mode 100644 backend/blog/migrations/0002_initial.py create mode 100644 backend/blog/migrations/__init__.py create mode 100644 backend/blog/models.py create mode 100644 backend/blog/resources.py create mode 100644 backend/celerybeat-schedule create mode 100644 backend/certificates/__init__.py create mode 100644 backend/certificates/admin.py create mode 100644 backend/certificates/apps.py create mode 100644 backend/certificates/migrations/0001_initial.py create mode 100644 backend/certificates/migrations/__init__.py create mode 100644 backend/certificates/models.py create mode 100644 backend/communications/admin.py create mode 100644 backend/communications/apps.py create mode 100644 backend/communications/fixtures/communications.json create mode 100644 backend/communications/migrations/0001_initial.py create mode 100644 backend/communications/migrations/0002_initial.py create mode 100644 backend/communications/migrations/__init__.py create mode 100644 backend/communications/models.py create mode 100644 backend/communications/push_notifications.py create mode 100644 backend/communications/resources.py create mode 100644 backend/communications/tasks.py create mode 100644 backend/communications/utils.py create mode 100644 backend/config/__init__.py create mode 100644 backend/config/asgi.py create mode 100644 backend/config/services/celery.py create mode 100644 backend/config/services/location.py create mode 100644 backend/config/services/notifications.py create mode 100644 backend/config/services/unfold.py create mode 100644 backend/config/services/zarinpal.py create mode 100644 backend/config/settings/base.py create mode 100644 backend/config/settings/development.py create mode 100644 backend/config/settings/production.py create mode 100644 backend/config/settings/test.py create mode 100644 backend/config/urls.py create mode 100644 backend/config/wsgi.py create mode 100644 backend/docker/entrypoint.sh create mode 100644 backend/docker/nginx.conf create mode 100644 backend/events/admin.py create mode 100644 backend/events/admin_forms.py create mode 100644 backend/events/apps.py create mode 100644 backend/events/fixtures/events.json create mode 100644 backend/events/migrations/0001_initial.py create mode 100644 backend/events/migrations/0002_initial.py create mode 100644 backend/events/migrations/0003_initial.py create mode 100644 backend/events/migrations/0004_event_registration_success_markdown.py create mode 100644 backend/events/migrations/0005_registration_cancellation_email_sent_at_and_more.py create mode 100644 backend/events/migrations/0006_alter_event_options_alter_registration_options_and_more.py create mode 100644 backend/events/migrations/0007_eventemaillog_deleted_at_eventemaillog_is_deleted_and_more.py create mode 100644 backend/events/migrations/0008_alter_eventemaillog_kind.py create mode 100644 backend/events/migrations/0009_registration_discount_amount_and_more.py create mode 100644 backend/events/migrations/0010_backfill_registration_discounts.py create mode 100644 backend/events/migrations/0011_eventemaillog_context_hash.py create mode 100644 backend/events/migrations/0012_alter_eventemaillog_kind.py create mode 100644 backend/events/migrations/__init__.py create mode 100644 backend/events/models.py create mode 100644 backend/events/resources.py create mode 100644 backend/events/tasks.py create mode 100644 backend/gallery/admin.py create mode 100644 backend/gallery/apps.py create mode 100644 backend/gallery/fixtures/gallery.json create mode 100644 backend/gallery/migrations/0001_initial.py create mode 100644 backend/gallery/migrations/0002_initial.py create mode 100644 backend/gallery/migrations/__init__.py create mode 100644 backend/gallery/models.py create mode 100644 backend/gallery/resources.py create mode 100644 backend/gallery/tasks.py create mode 100644 backend/manage.py create mode 100644 backend/payments/admin.py create mode 100644 backend/payments/apps.py create mode 100644 backend/payments/migrations/0001_initial.py create mode 100644 backend/payments/migrations/0002_initial.py create mode 100644 backend/payments/migrations/0003_payment_registration.py create mode 100644 backend/payments/migrations/__init__.py create mode 100644 backend/payments/models.py create mode 100644 backend/payments/resources.py create mode 100644 backend/requirements.txt create mode 100644 backend/static/css/styles.css create mode 100644 backend/static/img/logo.png create mode 100644 backend/static/js/push-notifications.js create mode 100644 backend/static/js/scripts.js create mode 100644 backend/static/js/sw.js create mode 100644 backend/templates/emails/announcement_email.html create mode 100644 backend/templates/emails/event_announcement.html create mode 100644 backend/templates/emails/event_invite_non_registered.html create mode 100644 backend/templates/emails/event_invite_non_registered.txt create mode 100644 backend/templates/emails/event_registration_cancellation.html create mode 100644 backend/templates/emails/event_registration_confirmation.html create mode 100644 backend/templates/emails/event_reminder.html create mode 100644 backend/templates/emails/newsletter_confirmation.html create mode 100644 backend/templates/emails/password_reset_email.html create mode 100644 backend/templates/emails/skyroom_credentials.html create mode 100644 backend/templates/emails/verification_email.html create mode 100644 backend/templates/emails/verification_success.html create mode 100644 backend/templates/forms/admin_announcement.html create mode 100644 backend/tests/__init__.py create mode 100644 backend/tests/integration/__init__.py create mode 100644 backend/tests/integration/test_events.py create mode 100644 backend/tests/integration/test_payments.py create mode 100644 backend/tests/integration/test_users.py create mode 100644 backend/tests/unit/__init__.py create mode 100644 backend/tests/unit/test_events.py create mode 100644 backend/tests/unit/test_payments.py create mode 100644 backend/tests/unit/test_users.py create mode 100644 backend/users/admin.py create mode 100644 backend/users/apps.py create mode 100644 backend/users/fixtures/agile.json create mode 100644 backend/users/fixtures/users.json create mode 100644 backend/users/migrations/0001_initial.py create mode 100644 backend/users/migrations/0002_alter_user_university.py create mode 100644 backend/users/migrations/0003_alter_user_university.py create mode 100644 backend/users/migrations/0004_major_university_models.py create mode 100644 backend/users/migrations/0005_populate_major_university.py create mode 100644 backend/users/migrations/0006_remove_legacy_fields.py create mode 100644 backend/users/migrations/__init__.py create mode 100644 backend/users/models.py create mode 100644 backend/users/resources.py create mode 100644 backend/users/signals.py create mode 100644 backend/users/tasks.py create mode 100644 backend/utils/admin.py create mode 100644 backend/utils/choices.py create mode 100644 backend/utils/models.py create mode 100644 backend/utils/templatetags/jalali.py create mode 100644 docker-compose.yml create mode 100644 frontend/.gitignore create mode 100644 frontend/Dockerfile create mode 100644 frontend/README.md create mode 100644 frontend/bun.lockb create mode 100644 frontend/components.json create mode 100644 frontend/eslint.config.js create mode 100644 frontend/index.html create mode 100644 frontend/nginx.conf create mode 100644 frontend/package-lock.json create mode 100644 frontend/package.json create mode 100644 frontend/postcss.config.js create mode 100644 frontend/public/enamad.png create mode 100644 frontend/public/favicon.ico create mode 100644 frontend/public/placeholder.svg create mode 100644 frontend/public/robots.txt create mode 100644 frontend/src/App.css create mode 100644 frontend/src/App.tsx create mode 100644 frontend/src/components/CouponDialogFa.tsx create mode 100644 frontend/src/components/Footer.tsx create mode 100644 frontend/src/components/Layout.tsx create mode 100644 frontend/src/components/Markdown.tsx create mode 100644 frontend/src/components/ModeToggle.tsx create mode 100644 frontend/src/components/Navbar.tsx create mode 100644 frontend/src/components/PaymentResult.tsx create mode 100644 frontend/src/components/ScrollToTop.tsx create mode 100644 frontend/src/components/SearchableCombobox.tsx create mode 100644 frontend/src/components/ThemeProvider.tsx create mode 100644 frontend/src/components/ui/accordion.tsx create mode 100644 frontend/src/components/ui/alert-dialog.tsx create mode 100644 frontend/src/components/ui/alert.tsx create mode 100644 frontend/src/components/ui/aspect-ratio.tsx create mode 100644 frontend/src/components/ui/avatar.tsx create mode 100644 frontend/src/components/ui/badge.tsx create mode 100644 frontend/src/components/ui/breadcrumb.tsx create mode 100644 frontend/src/components/ui/button.tsx create mode 100644 frontend/src/components/ui/calendar.tsx create mode 100644 frontend/src/components/ui/card.tsx create mode 100644 frontend/src/components/ui/carousel.tsx create mode 100644 frontend/src/components/ui/chart.tsx create mode 100644 frontend/src/components/ui/checkbox.tsx create mode 100644 frontend/src/components/ui/collapsible.tsx create mode 100644 frontend/src/components/ui/command.tsx create mode 100644 frontend/src/components/ui/context-menu.tsx create mode 100644 frontend/src/components/ui/dialog.tsx create mode 100644 frontend/src/components/ui/drawer.tsx create mode 100644 frontend/src/components/ui/dropdown-menu.tsx create mode 100644 frontend/src/components/ui/form.tsx create mode 100644 frontend/src/components/ui/hover-card.tsx create mode 100644 frontend/src/components/ui/input-otp.tsx create mode 100644 frontend/src/components/ui/input.tsx create mode 100644 frontend/src/components/ui/label.tsx create mode 100644 frontend/src/components/ui/menubar.tsx create mode 100644 frontend/src/components/ui/navigation-menu.tsx create mode 100644 frontend/src/components/ui/pagination.tsx create mode 100644 frontend/src/components/ui/popover.tsx create mode 100644 frontend/src/components/ui/progress.tsx create mode 100644 frontend/src/components/ui/radio-group.tsx create mode 100644 frontend/src/components/ui/resizable.tsx create mode 100644 frontend/src/components/ui/scroll-area.tsx create mode 100644 frontend/src/components/ui/select.tsx create mode 100644 frontend/src/components/ui/separator.tsx create mode 100644 frontend/src/components/ui/sheet.tsx create mode 100644 frontend/src/components/ui/sidebar.tsx create mode 100644 frontend/src/components/ui/skeleton.tsx create mode 100644 frontend/src/components/ui/slider.tsx create mode 100644 frontend/src/components/ui/sonner.tsx create mode 100644 frontend/src/components/ui/switch.tsx create mode 100644 frontend/src/components/ui/table.tsx create mode 100644 frontend/src/components/ui/tabs.tsx create mode 100644 frontend/src/components/ui/textarea.tsx create mode 100644 frontend/src/components/ui/toast.tsx create mode 100644 frontend/src/components/ui/toaster.tsx create mode 100644 frontend/src/components/ui/toggle-group.tsx create mode 100644 frontend/src/components/ui/toggle.tsx create mode 100644 frontend/src/components/ui/tooltip.tsx create mode 100644 frontend/src/components/ui/use-toast.ts create mode 100644 frontend/src/contexts/AuthContext.tsx create mode 100644 frontend/src/hooks/use-mobile.tsx create mode 100644 frontend/src/hooks/use-toast.ts create mode 100644 frontend/src/index.css create mode 100644 frontend/src/lib/api.ts create mode 100644 frontend/src/lib/swagger.json create mode 100644 frontend/src/lib/types.ts create mode 100644 frontend/src/lib/utils.ts create mode 100644 frontend/src/main.tsx create mode 100644 frontend/src/pages/AboutUs.tsx create mode 100644 frontend/src/pages/AdminEventDetail.tsx create mode 100644 frontend/src/pages/AdminEventEdit.tsx create mode 100644 frontend/src/pages/AdminEvents.tsx create mode 100644 frontend/src/pages/AdminLayout.tsx create mode 100644 frontend/src/pages/AdminUsers.tsx create mode 100644 frontend/src/pages/Auth.tsx create mode 100644 frontend/src/pages/Blog.tsx create mode 100644 frontend/src/pages/EventDetail.tsx create mode 100644 frontend/src/pages/EventFreeSuccessPage.tsx create mode 100644 frontend/src/pages/Events.tsx create mode 100644 frontend/src/pages/Home.tsx create mode 100644 frontend/src/pages/Index.tsx create mode 100644 frontend/src/pages/Logout.tsx create mode 100644 frontend/src/pages/NotFound.tsx create mode 100644 frontend/src/pages/PaymentResult.tsx create mode 100644 frontend/src/pages/Profile.tsx create mode 100644 frontend/src/pages/ResetPasswordConfirm.tsx create mode 100644 frontend/src/pages/ResetPasswordRequest.tsx create mode 100644 frontend/src/pages/VerifyEmail.tsx create mode 100644 frontend/src/vite-env.d.ts create mode 100644 frontend/tailwind.config.ts create mode 100644 frontend/tsconfig.app.json create mode 100644 frontend/tsconfig.json create mode 100644 frontend/tsconfig.node.json create mode 100644 frontend/vite.config.ts create mode 100644 grafana-datasources.yml create mode 100644 nginx-static.conf create mode 100644 prometheus.yml create mode 100644 screenshots/about.png create mode 100644 screenshots/admin.png create mode 100644 screenshots/event-detail.png create mode 100644 screenshots/events.png create mode 100644 screenshots/home.png create mode 100644 screenshots/profile.png diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6e32316 --- /dev/null +++ b/.env.example @@ -0,0 +1,67 @@ +# Let's Encrypt +NEXT_HOST=frontend-host-domain.example +DJANGO_HOST=backend-host-domain.example +LETSENCRYPT_EMAIL=admin@backend-host-domain.example + + +# Gunicorn tuning +GUNICORN_WORKERS=3 +GUNICORN_THREADS=2 + + +# Django Settings +DJANGO_SETTINGS_MODULE=config.settings.production +SECRET_KEY=DJANGO_SECRET_KEY +DEBUG=False +ALLOWED_HOSTS=frontend-host-domain.example,api-host.example + +# Database +DB_ENGINE=django.db.backends.postgresql +DB_NAME=db-name +DB_USER=db-user +DB_PASSWORD=db-password +DB_HOST=db-host +DB_PORT=5432 + +# Redis +REDIS_PASSWORD=redis-password +REDIS_URL=redis://:redis-password@redis:6379/0 + +# Celery +CELERY_BROKER_URL=redis://:redis-password@redis:6379/0 +CELERY_RESULT_BACKEND=redis://:redis-password@redis:6379/1 + +# Email Settings +EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST=email-host +EMAIL_PORT=587 +EMAIL_USE_TLS=True +EMAIL_HOST_USER=smtp-user +EMAIL_HOST_PASSWORD=smtp-password +DEFAULT_FROM_EMAIL=email-address + +# JWT Settings +JWT_SECRET_KEY=JWT_SECRET_KEY +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_LIFETIME=3600 +JWT_REFRESH_TOKEN_LIFETIME=86400 + +# CORS +CORS_ALLOWED_ORIGINS=https://frontend-host-domain.example + +# ZarinPal +ZARINPAL_MERCHANT_ID=**** +ZARINPAL_USE_SANDBOX=False +ZARINPAL_CALLBACK_URL=https://backend-callback-endpoint + +# Front-end +FRONTEND_ROOT=https://frontend-host-domain.example +FRONTEND_PASSWORD_RESET_PAGE=https://frontend-host-domain.example/reset-password + +# Optional test overrides +TEST_DB_ENGINE=django.db.backends.sqlite3 +TEST_DB_NAME=db.test.sqlite3 +TEST_DB_USER= +TEST_DB_PASSWORD= +TEST_DB_HOST= +TEST_DB_PORT= diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..23dfb9f --- /dev/null +++ b/.env.test @@ -0,0 +1,60 @@ +# Let's Encrypt +NEXT_HOST=frontend-host-domain.example +DJANGO_HOST=backend-host-domain.example +LETSENCRYPT_EMAIL=admin@backend-host-domain.example + + +# Gunicorn tuning +GUNICORN_WORKERS=3 +GUNICORN_THREADS=2 + + +# Django Settings +DJANGO_SETTINGS_MODULE=config.settings.test +SECRET_KEY=DJANGO_SECRET_KEY +DEBUG=False +ALLOWED_HOSTS=frontend-host-domain.example,api-host.example + +# Database (Optional) +TEST_DB_ENGINE=django.db.backends.sqlite3 +TEST_DB_NAME=db.test.sqlite3 +TEST_DB_USER= +TEST_DB_PASSWORD= +TEST_DB_HOST= +TEST_DB_PORT= + +# Redis +REDIS_PASSWORD=redis-password +REDIS_URL=redis://:redis-password@redis:6379/0 + +# Celery +CELERY_BROKER_URL=redis://:redis-password@redis:6379/0 +CELERY_RESULT_BACKEND=redis://:redis-password@redis:6379/1 + +# Email Settings +EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST=email-host +EMAIL_PORT=587 +EMAIL_USE_TLS=True +EMAIL_HOST_USER=smtp-user +EMAIL_HOST_PASSWORD=smtp-password +DEFAULT_FROM_EMAIL=email-address + +# JWT Settings +JWT_SECRET_KEY=JWT_SECRET_KEY +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_LIFETIME=3600 +JWT_REFRESH_TOKEN_LIFETIME=86400 + +# CORS +CORS_ALLOWED_ORIGINS=https://frontend-host-domain.example + +# ZarinPal +ZARINPAL_MERCHANT_ID=**** +ZARINPAL_USE_SANDBOX=False +ZARINPAL_CALLBACK_URL=https://backend-callback-endpoint + +# Front-end +FRONTEND_ROOT=https://frontend-host-domain.example +FRONTEND_PASSWORD_RESET_PAGE=https://frontend-host-domain.example/reset-password + diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml new file mode 100644 index 0000000..8b6b8d9 --- /dev/null +++ b/.github/workflows/ci-cd.yml @@ -0,0 +1,236 @@ +name: CI/CD + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + name: Backend & Frontend Checks + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: app + POSTGRES_PASSWORD: password + POSTGRES_USER: app + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U app -d app" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + SECRET_KEY: github-ci-secret-key + DEBUG: "False" + DJANGO_SETTINGS_MODULE: config.settings.test + ALLOWED_HOSTS: localhost,127.0.0.1,testserver + DJANGO_HOST: http://localhost:8000 + NEXT_HOST: localhost + LETSENCRYPT_EMAIL: ci@example.com + GUNICORN_WORKERS: "2" + GUNICORN_THREADS: "2" + DB_ENGINE: django.db.backends.postgresql + DB_NAME: app + DB_USER: app + DB_PASSWORD: password + DB_HOST: localhost + DB_PORT: "5432" + TEST_DB_ENGINE: django.db.backends.postgresql + TEST_DB_NAME: app + TEST_DB_USER: app + TEST_DB_PASSWORD: password + TEST_DB_HOST: localhost + TEST_DB_PORT: "5432" + REDIS_PASSWORD: "" + REDIS_URL: redis://localhost:6379/0 + CELERY_BROKER_URL: redis://localhost:6379/0 + CELERY_RESULT_BACKEND: redis://localhost:6379/1 + EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend + EMAIL_HOST: localhost + EMAIL_PORT: "1025" + EMAIL_USE_TLS: "False" + EMAIL_HOST_USER: "" + EMAIL_HOST_PASSWORD: "" + DEFAULT_FROM_EMAIL: noreply@example.com + CORS_ALLOWED_ORIGINS: http://localhost:3000 + FRONTEND_ROOT: http://localhost:3000 + FRONTEND_PASSWORD_RESET_PAGE: http://localhost:3000/reset-password + FRONTEND_CALLBACK_URL: http://localhost:3000/payments/result + JWT_SECRET_KEY: test-jwt-key + JWT_ALGORITHM: HS256 + JWT_ACCESS_TOKEN_LIFETIME: "3600" + JWT_REFRESH_TOKEN_LIFETIME: "86400" + ZARINPAL_MERCHANT_ID: sandbox-merchant + ZARINPAL_USE_SANDBOX: "True" + ZARINPAL_CALLBACK_URL: http://localhost:8000/payments/zarinpal/callback + PYTHON_VERSION: "3.12" + NODE_VERSION: "20" + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + cache-dependency-path: backend/requirements.txt + + - name: Install backend dependencies + working-directory: backend + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install coverage + + - name: Prepare .env from template + run: | + cp .env.test .env + python - <<'PY' + import os + from pathlib import Path + + env_path = Path(".env") + overrides = { + "SECRET_KEY": os.environ["SECRET_KEY"], + "DJANGO_SETTINGS_MODULE": os.environ["DJANGO_SETTINGS_MODULE"], + "ALLOWED_HOSTS": os.environ["ALLOWED_HOSTS"], + "DJANGO_HOST": os.environ["DJANGO_HOST"], + "NEXT_HOST": os.environ["NEXT_HOST"], + "LETSENCRYPT_EMAIL": os.environ["LETSENCRYPT_EMAIL"], + "GUNICORN_WORKERS": os.environ["GUNICORN_WORKERS"], + "GUNICORN_THREADS": os.environ["GUNICORN_THREADS"], + "DB_ENGINE": os.environ["DB_ENGINE"], + "DB_NAME": os.environ["DB_NAME"], + "DB_USER": os.environ["DB_USER"], + "DB_PASSWORD": os.environ["DB_PASSWORD"], + "DB_HOST": os.environ["DB_HOST"], + "DB_PORT": os.environ["DB_PORT"], + "TEST_DB_ENGINE": os.environ["TEST_DB_ENGINE"], + "TEST_DB_NAME": os.environ["TEST_DB_NAME"], + "TEST_DB_USER": os.environ["TEST_DB_USER"], + "TEST_DB_PASSWORD": os.environ["TEST_DB_PASSWORD"], + "TEST_DB_HOST": os.environ["TEST_DB_HOST"], + "TEST_DB_PORT": os.environ["TEST_DB_PORT"], + "REDIS_PASSWORD": os.environ["REDIS_PASSWORD"], + "REDIS_URL": os.environ["REDIS_URL"], + "CELERY_BROKER_URL": os.environ["CELERY_BROKER_URL"], + "CELERY_RESULT_BACKEND": os.environ["CELERY_RESULT_BACKEND"], + "EMAIL_BACKEND": os.environ["EMAIL_BACKEND"], + "EMAIL_HOST": os.environ["EMAIL_HOST"], + "EMAIL_PORT": os.environ["EMAIL_PORT"], + "EMAIL_USE_TLS": os.environ["EMAIL_USE_TLS"], + "EMAIL_HOST_USER": os.environ["EMAIL_HOST_USER"], + "EMAIL_HOST_PASSWORD": os.environ["EMAIL_HOST_PASSWORD"], + "DEFAULT_FROM_EMAIL": os.environ["DEFAULT_FROM_EMAIL"], + "CORS_ALLOWED_ORIGINS": os.environ["CORS_ALLOWED_ORIGINS"], + "FRONTEND_ROOT": os.environ["FRONTEND_ROOT"], + "FRONTEND_PASSWORD_RESET_PAGE": os.environ["FRONTEND_PASSWORD_RESET_PAGE"], + "FRONTEND_CALLBACK_URL": os.environ["FRONTEND_CALLBACK_URL"], + "JWT_SECRET_KEY": os.environ["JWT_SECRET_KEY"], + "JWT_ALGORITHM": os.environ["JWT_ALGORITHM"], + "JWT_ACCESS_TOKEN_LIFETIME": os.environ["JWT_ACCESS_TOKEN_LIFETIME"], + "JWT_REFRESH_TOKEN_LIFETIME": os.environ["JWT_REFRESH_TOKEN_LIFETIME"], + "ZARINPAL_MERCHANT_ID": os.environ["ZARINPAL_MERCHANT_ID"], + "ZARINPAL_USE_SANDBOX": os.environ["ZARINPAL_USE_SANDBOX"], + "ZARINPAL_CALLBACK_URL": os.environ["ZARINPAL_CALLBACK_URL"], + } + + lines = [] + for raw in env_path.read_text().splitlines(): + if not raw or raw.lstrip().startswith("#"): + lines.append(raw) + continue + key, _, current = raw.partition("=") + value = overrides.get(key, current) + lines.append(f"{key}={value}") + + env_path.write_text("\n".join(lines) + "\n") + PY + + - name: Run database migrations + working-directory: backend + env: + DJANGO_SETTINGS_MODULE: ${{ env.DJANGO_SETTINGS_MODULE }} + run: python manage.py migrate --noinput + + - name: Run Django tests with coverage + working-directory: backend + env: + DJANGO_SETTINGS_MODULE: ${{ env.DJANGO_SETTINGS_MODULE }} + run: | + coverage run --rcfile=.coveragerc manage.py test --settings=config.settings.test --verbosity 2 + coverage report -m + coverage xml + coverage html + + - name: Upload backend coverage artifacts + uses: actions/upload-artifact@v4 + with: + name: backend-coverage + path: | + backend/.coverage + backend/coverage.xml + backend/htmlcov + retention-days: 14 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: frontend/package-lock.json + + - name: Install frontend dependencies + working-directory: frontend + run: npm install --no-audit --no-fund + + - name: Build frontend + working-directory: frontend + env: + CI: "true" + run: npm run build + + deploy: + name: Deploy to Production + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + timeout-minutes: 30 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Deploy over SSH + uses: appleboy/ssh-action@v1.2.0 + with: + host: ${{ secrets.DEPLOY_HOST }} + username: ${{ secrets.DEPLOY_USER }} + key: ${{ secrets.DEPLOY_SSH_KEY }} + port: ${{ secrets.DEPLOY_PORT }} + script: | + set -e + cd "${{ secrets.DEPLOY_PATH }}" + git fetch --prune + git reset --hard origin/main + docker compose pull + docker compose up -d --build --remove-orphans + docker image prune -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b0abad0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules + +# next.js +/.next/ +/out/ + +# production +/build + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env* +!.env.example +!.env.test + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +.vscode \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + Copyright (C) + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..fa3597c --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# East Guilan CE + +**Full-stack portal** for the East Guilan Computer Engineering Association. The repo hosts a Django/Ninja API backend and a Vite + React + shadcn UI frontend, both orchestrated via Docker Compose. + +## Overview +- **Backend**: Django + Ninja-based REST API, PostgreSQL, Redis, Celery, Traefik routing, and Prometheus/metrics. +- **Frontend**: Vite + React 18 with TanStack Query, shadcn/ui components, RTL layout, and staff-only admin tooling. +- **Dev surface**: `docker-compose.yml` brings up Traefik, PostgreSQL, Redis, Django (web/worker/beat), frontend, static/nginx, Prometheus exporters, etc. + +## Architecture +1. **API layer (`backend/api`)** exposes auth, blog, event, gallery, payment, and communication routers with Ninja schemas. JWT authentication protects secured routes. +2. **Django apps** (`users`, `blog`, `events`, `payments`, `gallery`, etc.) provide models, management commands, and async tasks/Celery workers. +3. **Frontend** (`frontend/`) consumes the API, handles login flows, event listings, the admin dashboard, rich markdown rendering, and sonner toasts. + +## Getting started +1. Copy `.env.example` to `.env` and configure secrets. +2. `docker compose up --build` to start the full stack. +3. Backend tests: `docker compose exec backend python manage.py test --settings=config.settings.test`. +4. Frontend dev: `docker compose exec frontend npm run dev -- --host`. + +## Screenshots + +### Main pages: +![Home Screenshot](./screenshots/home.png) +![Home Screenshot](./screenshots/about.png) + +### User/Admin Dashboard: +![Event Detail](./screenshots/profile.png) +![Admin Dashboard](./screenshots/admin.png) + +### Event Pages: +![Events List](./screenshots/events.png) +![Event Detail](./screenshots/event-detail.png) + +### Blog Pages: + +Soon... + +See the [`backend/README.md`](backend/README.md) and [`frontend/README.md`](frontend/README.md) for detailed instructions. diff --git a/alert.rules.yml b/alert.rules.yml new file mode 100644 index 0000000..77dfced --- /dev/null +++ b/alert.rules.yml @@ -0,0 +1,26 @@ +groups: + - name: storage + rules: + - alert: DiskSpaceWarning + expr: (node_filesystem_size_bytes{fstype!~"tmpfs|overlay"} - node_filesystem_free_bytes{fstype!~"tmpfs|overlay"}) / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"} > 0.80 + for: 10m + labels: + severity: warning + annotations: + summary: "Disk >80% on {{ $labels.instance }} {{ $labels.mountpoint }}" + + - alert: DiskSpaceCritical + expr: (node_filesystem_size_bytes{fstype!~"tmpfs|overlay"} - node_filesystem_free_bytes{fstype!~"tmpfs|overlay"}) / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"} > 0.90 + for: 5m + labels: + severity: critical + annotations: + summary: "Disk >90% on {{ $labels.instance }} {{ $labels.mountpoint }}" + + - alert: DiskInodesLow + expr: (node_filesystem_files{fstype!~"tmpfs|overlay"} - node_filesystem_files_free{fstype!~"tmpfs|overlay"}) / node_filesystem_files{fstype!~"tmpfs|overlay"} > 0.80 + for: 10m + labels: + severity: warning + annotations: + summary: "Inodes >80% on {{ $labels.instance }} {{ $labels.mountpoint }}" diff --git a/alertmanager.yml b/alertmanager.yml new file mode 100644 index 0000000..0fbe5a6 --- /dev/null +++ b/alertmanager.yml @@ -0,0 +1,14 @@ +route: + receiver: default + routes: + - matchers: + - severity="critical" + receiver: critical + +receivers: + - name: default + email_configs: + - to: you@example.com + - name: critical + email_configs: + - to: oncall@example.com diff --git a/backend/.coveragerc b/backend/.coveragerc new file mode 100644 index 0000000..2cad10f --- /dev/null +++ b/backend/.coveragerc @@ -0,0 +1,31 @@ +[run] +branch = True +source = + users + api + utils + payments + communications + gallery + events + blog + config +omit = + */migrations/* + */tests/* + */__init__.py + config/settings/* + config/urls.py + config/wsgi.py + config/asgi.py + +[report] +skip_empty = True +show_missing = True +precision = 2 + +[html] +directory = htmlcov + +[xml] +output = coverage.xml diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..1601d03 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,139 @@ +# Django # +*.log +*.pot +*.pyc +__pycache__ +db.sqlite3 +db.test.sqlite3 +media + +# Backup files # +*.bak + +# If you are using PyCharm # +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# File-based project format +*.iws + +# IntelliJ +out/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Python # +*.py[cod] +*$py.class + +# Distribution / packaging +.Python build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.whl +*.egg-info/ +.installed.cfg +*.egg +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery +celerybeat-schedule.* + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# Sublime Text # +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache +*.sublime-workspace +*.sublime-project + +# sftp configuration file +sftp-config.json + +# Package control specific files Package +Control.last-run +Control.ca-list +Control.ca-bundle +Control.system-ca-bundle +GitHub.sublime-settings + +# Visual Studio Code # +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..501a9f9 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,34 @@ +FROM python:3.13-slim + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Set work directory +WORKDIR /app + +# Install system dependencies +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + postgresql-client \ + build-essential \ + libpq-dev \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt + +# Copy project +COPY . /app/ + +# Create directories for static and media files +RUN mkdir -p /app/static /app/media +# COPY ./static/ /app/static/ + +# Collect static files +RUN python manage.py collectstatic --noinput || true + +EXPOSE 8000 + +CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers=3", "--threads=2", "--timeout=60"] diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..1a1b6e9 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,38 @@ +# Backend + +## Stack +- Django 5+ with Ninja API routers, JWT auth, and Ninja schemas. +- PostgreSQL + Redis + Celery + Gunicorn orchestrated via Docker Compose. +- Traefik handles TLS termination and routing to `/api`, `/admin`, `/static`, `/media`. +- Metrics exporters (Prometheus, node exporter, PostgreSQL exporter) are wired in `docker-compose.yml`. + +## Key apps + +| App | Responsibilities | +| --- | --- | +| `users` | Custom `User` model, email verification, password resets, soft deletes. | +| `blog` | Posts, comments, categories/tags, likes, admin delete/restore operations. | +| `events` | Events, registrations, invitations, registration emails, Celery tasks. | +| `payments` | Discount codes, payment tracking linked to registrations. | + +## API highlights +- **Authentication** (`/api/auth/*`): register, login, refresh, profile, delete profile picture, deleted users, filtered user lists. +- **Blog** (`/api/blog/*`): posts/comments, soft delete/restore, likes, categories/tags APIs. +- **Events** (`/api/events/*`): list, detail, create/update/delete, admin endpoints for event/registration detail and paginated/filterable registrations. +- **Payments** (`/api/payments/*`): create payment, get by ref, discounts. + +## Running locally +```bash +docker compose build backend +docker compose run --rm backend python manage.py migrate +``` + +### Tests +```bash +docker compose run --rm backend python manage.py test --settings=config.settings.test +``` + +### Admin tooling +- Ninja routers live under `backend/api/views`. Schemas are in `backend/api/schemas`. +- JWT auth files: `backend/api/authentication.py`. +- Celery configs in `backend/config/services/celery.py` and tasks (events, users, communications). diff --git a/backend/api/authentication.py b/backend/api/authentication.py new file mode 100644 index 0000000..98bc451 --- /dev/null +++ b/backend/api/authentication.py @@ -0,0 +1,41 @@ +from django.conf import settings + +from ninja.security import HttpBearer +from datetime import datetime, timedelta, UTC +import jwt + +from users.models import User + +class JWTAuth(HttpBearer): + def authenticate(self, request, token): + try: + payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM]) + user_id = payload.get('user_id') + if user_id: + user = User.objects.get(id=user_id, is_email_verified=True, is_active=True) + return user + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, User.DoesNotExist): + pass + return None + +def create_jwt_token(user): + """Create JWT token for user""" + payload = { + 'user_id': user.id, + 'email': user.email, + 'exp': datetime.now(UTC) + timedelta(seconds=settings.JWT_ACCESS_TOKEN_LIFETIME), + 'iat': datetime.now(UTC), + } + return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + +def create_refresh_token(user): + """Create refresh token for user""" + payload = { + 'user_id': user.id, + 'type': 'refresh', + 'exp': datetime.now(UTC) + timedelta(seconds=settings.JWT_REFRESH_TOKEN_LIFETIME), + 'iat': datetime.now(UTC), + } + return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + +jwt_auth = JWTAuth() diff --git a/backend/api/schemas/__init__.py b/backend/api/schemas/__init__.py new file mode 100644 index 0000000..e1eef64 --- /dev/null +++ b/backend/api/schemas/__init__.py @@ -0,0 +1,31 @@ +"""Aggregate exports for API schemas and shared response payloads.""" + +from typing import Optional + +from ninja import Schema + +from api.schemas.auth import * +from api.schemas.blog import * +from api.schemas.gallery import * +from api.schemas.events import * +from api.schemas.communications import * +from api.schemas.certificates import * + + +class MessageSchema(Schema): + """Basic success response containing a message.""" + message: str + + +class ErrorSchema(Schema): + """Standard error payload with optional details.""" + error: str + details: Optional[str] = None + + +def rebuild_comment_schema() -> None: + """Ensure the self-referential CommentSchema is fully initialized.""" + CommentSchema.model_rebuild() + + +rebuild_comment_schema() diff --git a/backend/api/schemas/auth.py b/backend/api/schemas/auth.py new file mode 100644 index 0000000..9513b1d --- /dev/null +++ b/backend/api/schemas/auth.py @@ -0,0 +1,129 @@ +"""Authentication-related API schemas.""" + +from ninja import Schema, ModelSchema +from typing import Optional + +from users.models import User + + +class UserRegistrationSchema(Schema): + username: str + email: str + password: str + first_name: Optional[str] = None + last_name: Optional[str] = None + university: Optional[str] = None + student_id: Optional[str] = None + year_of_study: Optional[int] = None + major: Optional[str] = None + +class UserLoginSchema(Schema): + email: str + password: str + +class UserProfileSchema(ModelSchema): + profile_picture: Optional[str] = None + student_id: Optional[str] = None + major: Optional[str] = None + university: Optional[str] = None + + class Meta: + model = User + fields = [ + 'id', + 'username', + 'email', + 'first_name', + 'last_name', + 'student_id', + 'year_of_study', + 'major', + 'university', + 'bio', + 'date_joined', + 'is_email_verified', + 'is_active', + 'is_staff', + 'is_superuser', + 'is_deleted', + 'deleted_at', + ] + + @staticmethod + def resolve_major(obj): + return obj.get_major_display() + + @staticmethod + def resolve_university(obj): + return obj.get_university_display() + + @staticmethod + def resolve_profile_picture(obj, context): + """ + Resolves the absolute URL for the profile picture. + `context` contains the request object, which is needed for build_absolute_uri. + """ + request = context['request'] + if obj.profile_picture and hasattr(obj.profile_picture, 'url'): + return request.build_absolute_uri(obj.profile_picture.url) + return None + + +class UserListSchema(ModelSchema): + major: Optional[str] = None + university: Optional[str] = None + + class Meta: + model = User + fields = [ + 'id', + 'username', + 'email', + 'first_name', + 'last_name', + 'is_active', + 'is_staff', + 'is_superuser', + 'date_joined', + 'major', + 'university', + ] + + @staticmethod + def resolve_full_name(obj): + return obj.get_full_name() + + @staticmethod + def resolve_major(obj): + return obj.get_major_display() + + @staticmethod + def resolve_university(obj): + return obj.get_university_display() + +class UserUpdateSchema(Schema): + first_name: Optional[str] = None + last_name: Optional[str] = None + bio: Optional[str] = None + year_of_study: Optional[int] = None + major: Optional[str] = None + university: Optional[str] = None + student_id: Optional[str] = None + +class TokenSchema(Schema): + access_token: str + refresh_token: str + token_type: str = "bearer" + +class TokenRefreshIn(Schema): + refresh_token: str + +class PasswordResetRequestSchema(Schema): + email: str + +class PasswordResetConfirmSchema(Schema): + token: str + new_password: str + +class UsernameCheckSchema(Schema): + exists: bool diff --git a/backend/api/schemas/blog.py b/backend/api/schemas/blog.py new file mode 100644 index 0000000..0ed093a --- /dev/null +++ b/backend/api/schemas/blog.py @@ -0,0 +1,87 @@ +"""Blog API schemas.""" + +from ninja import Schema, ModelSchema +from typing import Optional, List +from datetime import datetime + +from blog.models import Category, Tag, Comment + + +class CategorySchema(ModelSchema): + class Config: + model = Category + model_fields = ['id', 'name', 'slug', 'description'] + +class TagSchema(ModelSchema): + class Config: + model = Tag + model_fields = ['id', 'name', 'slug'] + +class AuthorSchema(Schema): + id: int + username: str + first_name: str + last_name: str + profile_picture: Optional[str] = None + + @staticmethod + def resolve_profile_picture(obj, context): + request = context['request'] + if obj.profile_picture and hasattr(obj.profile_picture, 'url'): + return request.build_absolute_uri(obj.profile_picture.url) + return None + +class PostListSchema(Schema): + id: int + title: str + slug: str + excerpt: str + author: AuthorSchema + featured_image: Optional[str] = None + status: str + published_at: Optional[datetime] = None + category: Optional[CategorySchema] = None + tags: List[TagSchema] + is_featured: bool + created_at: datetime + reading_time: int + +class PostDetailSchema(PostListSchema): + content: str + content_html: str + +class PostCreateSchema(Schema): + title: str + content: str + excerpt: Optional[str] = None + category_id: Optional[int] = None + tag_ids: Optional[List[int]] = [] + status: str = "draft" + is_featured: bool = False + +class CommentSchema(ModelSchema): + author: AuthorSchema + replies: List['CommentSchema'] = [] + post_id: int + post_title: str + post_slug: str + + class Config: + model = Comment + model_fields = ['id', 'content', 'created_at', 'is_approved'] + + @staticmethod + def resolve_post_id(obj): + return obj.post_id + + @staticmethod + def resolve_post_title(obj): + return obj.post.title + + @staticmethod + def resolve_post_slug(obj): + return obj.post.slug + +class CommentCreateSchema(Schema): + content: str + parent_id: Optional[int] = None diff --git a/backend/api/schemas/certificates.py b/backend/api/schemas/certificates.py new file mode 100644 index 0000000..ab1e91a --- /dev/null +++ b/backend/api/schemas/certificates.py @@ -0,0 +1,70 @@ +"""API payloads for certificate operations.""" + +from datetime import datetime +from typing import List, Optional + +from ninja import Schema + + +class SkillSchema(Schema): + id: int + name: str + description: Optional[str] = None + + +class CertificateTemplateOut(Schema): + id: int + event_id: int + event_title: str + image_url: Optional[str] + skill_ids: List[int] + skills: List[SkillSchema] + + +class CertificateGenerationItem(Schema): + user_id: int + score: int + title: Optional[str] = None + description: Optional[str] = None + skill_ids: Optional[List[int]] = None + issued_at: Optional[datetime] = None + expires_at: Optional[datetime] = None + + +class CertificateGenerationPayload(Schema): + entries: List[CertificateGenerationItem] + default_title: Optional[str] = None + default_description: Optional[str] = None + + +class UserCertificateOut(Schema): + id: int + user_id: int + user_name: str + event_id: int + title: str + certificate_id: str + certificate_code: str + score: int + score_label: str + image_url: Optional[str] + + +class CertificateGenerationResponse(Schema): + certificates: List[UserCertificateOut] + + +class CertificateVerificationOut(Schema): + certificate_id: str + certificate_code: str + user_id: int + user_name: str + event_id: int + event_title: str + title: str + score: int + score_label: str + issued_at: datetime + expires_at: Optional[datetime] = None + image_url: Optional[str] = None + skills: List[str] diff --git a/backend/api/schemas/communications.py b/backend/api/schemas/communications.py new file mode 100644 index 0000000..ec4a206 --- /dev/null +++ b/backend/api/schemas/communications.py @@ -0,0 +1,124 @@ +"""Schemas for communications-related endpoints.""" + +from datetime import datetime +from typing import Optional, List + +from ninja import Schema, ModelSchema + +from api.schemas import AuthorSchema +from communications.models import ( + Announcement, + NewsletterSubscription, + PushNotificationDevice +) + + +class AnnouncementSchema(ModelSchema): + author: AuthorSchema + content_html: str + + class Config: + model = Announcement + model_fields = [ + 'id', 'title', 'content', 'announcement_type', 'priority', + 'is_published', 'publish_date', 'send_email', 'send_push', + 'target_audience', 'email_sent', 'push_sent', 'created_at', 'updated_at' + ] + + @staticmethod + def resolve_content_html(obj): + return obj.content_html + +class AnnouncementListSchema(Schema): + id: int + title: str + content: str + announcement_type: str + priority: str + author: AuthorSchema + is_published: bool + publish_date: Optional[datetime] = None + target_audience: str + created_at: datetime + +class AnnouncementCreateSchema(Schema): + title: str + content: str + announcement_type: str = "general" + priority: str = "normal" + target_audience: str = "all" + is_published: bool = False + publish_date: Optional[datetime] = None + send_email: bool = False + send_push: bool = False + +class AnnouncementUpdateSchema(Schema): + title: Optional[str] = None + content: Optional[str] = None + announcement_type: Optional[str] = None + priority: Optional[str] = None + target_audience: Optional[str] = None + is_published: Optional[bool] = None + publish_date: Optional[datetime] = None + send_email: Optional[bool] = None + send_push: Optional[bool] = None + +class NewsletterSubscriptionSchema(ModelSchema): + user: Optional[AuthorSchema] = None + + class Config: + model = NewsletterSubscription + model_fields = [ + 'id', 'email', 'is_active', 'subscribed_categories', + 'confirmed_at', 'created_at' + ] + +class NewsletterSubscribeSchema(Schema): + email: str + subscribed_categories: Optional[List[str]] = [] + +class NewsletterUnsubscribeSchema(Schema): + email: str + +class PushDeviceSchema(ModelSchema): + user: AuthorSchema + + class Config: + model = PushNotificationDevice + model_fields = [ + 'id', 'device_token', 'device_type', 'is_active', 'created_at' + ] + +class PushDeviceCreateSchema(Schema): + device_token: str + device_type: str = "web" + +class PushDeviceUpdateSchema(Schema): + is_active: bool + +class PushNotificationSchema(Schema): + title: str + body: str + data: Optional[dict] = None + target_audience: str = "all" + +class MessageResponseSchema(Schema): + """Simple message payload for API responses.""" + message: str + success: bool = True + +class AnnouncementStatsSchema(Schema): + """Summary statistics for announcements.""" + total_announcements: int + published_announcements: int + draft_announcements: int + urgent_announcements: int + email_sent_count: int + push_sent_count: int + +class NewsletterStatsSchema(Schema): + """Summary statistics for newsletter subscriptions.""" + total_subscriptions: int + active_subscriptions: int + confirmed_subscriptions: int + recent_subscriptions: int diff --git a/backend/api/schemas/events.py b/backend/api/schemas/events.py new file mode 100644 index 0000000..2cbd9ea --- /dev/null +++ b/backend/api/schemas/events.py @@ -0,0 +1,247 @@ +"""Event and gallery API schemas.""" + +from uuid import UUID +from ninja import ModelSchema, Schema +from pydantic import field_validator +from typing import Literal, Optional, List +from datetime import datetime + +from api.schemas.blog import AuthorSchema +from events.models import Event, Registration +from gallery.models import Gallery +from payments.models import Payment + + +class EventGallerySchema(ModelSchema): + """Schema representing gallery items associated with an event.""" + uploaded_by: AuthorSchema + file_size_mb: float + markdown_url: str + absolute_image_url: Optional[str] = None + + class Config: + model = Gallery + model_fields = ['id', 'title', 'description', 'image', 'alt_text', + 'width', 'height', 'is_public', 'created_at'] + + @staticmethod + def resolve_absolute_image_url(obj, context): + request = context['request'] + if obj.image and hasattr(obj.image, 'url'): + return request.build_absolute_uri(obj.image.url) + return None + +class EventSchema(ModelSchema): + """Schema providing full event details for API responses.""" + gallery_images: List[EventGallerySchema] + description_html: str + registration_count: int + absolute_featured_image_url: Optional[str] = None + + class Config: + model = Event + model_fields = [ + 'id', 'title', 'slug', 'description', 'featured_image', 'event_type', + 'address', 'location', 'online_link', 'start_time', 'end_time', + 'registration_start_date', 'registration_end_date', 'registration_success_markdown', + 'capacity', 'price', 'status', 'created_at', 'updated_at' + ] + + @staticmethod + def resolve_absolute_featured_image_url(obj, context): + request = context['request'] + if obj.featured_image and hasattr(obj.featured_image, 'url'): + return request.build_absolute_uri(obj.featured_image.url) + return None + + @staticmethod + def resolve_registration_count(obj): + return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count() + + @staticmethod + def resolve_description_html(obj): + return obj.description_html + + +class EventListSchema(Schema): + """Condensed event representation for list endpoints.""" + id: int + title: str + slug: str + featured_image: Optional[str] = None + absolute_featured_image_url: Optional[str] = None + event_type: str + start_time: datetime + end_time: datetime + registration_start_date: Optional[datetime] = None + registration_end_date: Optional[datetime] = None + capacity: Optional[int] = None + price: Optional[float] = None + status: str + registration_count: int + created_at: datetime + + @staticmethod + def resolve_absolute_featured_image_url(obj, context): + request = context['request'] + if obj.featured_image and hasattr(obj.featured_image, 'url'): + return request.build_absolute_uri(obj.featured_image.url) + return None + + @staticmethod + def resolve_registration_count(obj): + return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count() + +class EventCreateSchema(Schema): + """Payload for creating events via the API.""" + title: str + description: str + event_type: str + address: Optional[str] = None + location: Optional[str] = None + online_link: Optional[str] = None + start_time: datetime + end_time: datetime + registration_start_date: Optional[datetime] = None + registration_end_date: Optional[datetime] = None + capacity: Optional[int] = None + price: Optional[float] = None + status: str = "draft" + gallery_image_ids: Optional[List[int]] = [] + +class EventUpdateSchema(Schema): + """Payload for updating events via the API.""" + title: Optional[str] = None + description: Optional[str] = None + event_type: Optional[str] = None + address: Optional[str] = None + location: Optional[str] = None + online_link: Optional[str] = None + start_time: Optional[datetime] = None + end_time: Optional[datetime] = None + registration_start_date: Optional[datetime] = None + registration_end_date: Optional[datetime] = None + capacity: Optional[int] = None + price: Optional[float] = None + status: Optional[str] = None + gallery_image_ids: Optional[List[int]] = None + +class RegistrationSchema(ModelSchema): + """Schema describing a registration entry with event context.""" + user: AuthorSchema + event: EventListSchema + discount_code: str | None = None + + class Config: + model = Registration + model_fields = [ + 'id', + 'status', + 'registered_at', + 'ticket_id', + 'discount_amount', + 'final_price', + 'created_at', + 'updated_at', + ] + + @staticmethod + def resolve_discount_code(obj): + return obj.discount_code.code if obj.discount_code else None + + +class AdminUserSchema(Schema): + id: int + username: str + first_name: str + last_name: str + email: str + + +class PaymentAdminSchema(Schema): + id: int + authority: Optional[str] + ref_id: Optional[str] + status: int + status_label: str + base_amount: int + discount_amount: int + amount: int + verified_at: Optional[datetime] + created_at: datetime + discount_code: Optional[str] + user: AdminUserSchema + + @field_validator("discount_code", mode="before") + def normalize_discount_code(cls, value): + if value is None: + return None + if hasattr(value, "code"): + return value.code + return str(value) + + +class RegistrationAdminSchema(Schema): + id: int + ticket_id: UUID + status: str + status_label: str + registered_at: datetime + final_price: Optional[int] + discount_amount: Optional[int] + user: AdminUserSchema + payments: List[PaymentAdminSchema] + + +class EventAdminDetailSchema(EventSchema): + registrations: List[RegistrationAdminSchema] = [] + + @staticmethod + def resolve_registrations(obj): + return obj.registrations.select_related("user").prefetch_related( + "payments__discount_code" + ).order_by("-registered_at") + +class PaginatedRegistrationSchema(Schema): + count: int + next: Optional[str] = None + previous: Optional[str] = None + results: List[RegistrationAdminSchema] + +class RegistrationStatusUpdateSchema(Schema): + status: str + +class RegisterationDetailSchema(Schema): + """Detailed registration information with associated event metadata.""" + event_image: Optional[str] + event_title: str + event_type: str + ticket_id: UUID + status: str + registered_at: datetime + success_markdown: Optional[str] + +class EventBriefSchema(Schema): + """Minimal event representation used for nested responses.""" + id: int + title: str + slug: str + start_date: datetime + end_date: Optional[datetime] = None + location: Optional[str] = None + price: int + absolute_image_url: Optional[str] = None + +class MyEventRegistrationOut(Schema): + """Registration information as returned to authenticated users.""" + id: int + created_at: datetime + status: Literal["pending", "confirmed", "cancelled", "attended"] + event: EventBriefSchema + +class RegistrationStatusOut(Schema): + is_registered: bool + + +class RegistrationCreateSchema(Schema): + discount_code: Optional[str] = None diff --git a/backend/api/schemas/gallery.py b/backend/api/schemas/gallery.py new file mode 100644 index 0000000..d75251f --- /dev/null +++ b/backend/api/schemas/gallery.py @@ -0,0 +1,27 @@ +"""Schemas for gallery resources.""" + +from ninja import Schema, ModelSchema +from typing import Optional + +from api.schemas.blog import AuthorSchema +from gallery.models import Gallery + + +class GallerySchema(ModelSchema): + """Serialized representation of a gallery image.""" + uploaded_by: AuthorSchema + file_size_mb: float + markdown_url: str + + class Config: + model = Gallery + model_fields = ['id', 'title', 'description', 'image', 'alt_text', + 'width', 'height', 'is_public', 'created_at'] + + +class GalleryCreateSchema(Schema): + """Payload for creating a gallery entry.""" + title: str + description: Optional[str] = None + alt_text: Optional[str] = None + is_public: bool = True diff --git a/backend/api/schemas/payments.py b/backend/api/schemas/payments.py new file mode 100644 index 0000000..7b7b274 --- /dev/null +++ b/backend/api/schemas/payments.py @@ -0,0 +1,35 @@ +from ninja import Schema + + +class CreatePaymentIn(Schema): + event_id: int + description: str + discount_code: str | None = None + mobile: str | None = None + email: str | None = None + + +class CreatePaymentOut(Schema): + start_pay_url: str | None = None + authority: str | None = None + base_amount: int + discount_amount: int + amount: int + +class PaymentDetailOut(Schema): + ref_id: str | None = None + authority: str | None = None + base_amount: int + discount_amount: int + amount: int + status: str + verified_at: str | None = None + event: dict + +class CouponVerifyIn(Schema): + event_id: int + code: str + +class CouponVerifyOut(Schema): + discount_amount: int + final_price: int diff --git a/backend/api/urls.py b/backend/api/urls.py new file mode 100644 index 0000000..d114240 --- /dev/null +++ b/backend/api/urls.py @@ -0,0 +1,16 @@ +from ninja import Router + +from api.views import * +from api.views import certificates_router + +router = Router() + +router.add_router("auth/", auth_router, tags=["Authentication"]) +router.add_router("blog/", blog_router, tags=["Blog"]) +router.add_router("gallery/", gallery_router, tags=["Gallery"]) +router.add_router("events/", events_router, tags=["Events"]) +router.add_router("communications/", communications_router, tags=["Communications"]) +router.add_router("payments/", payments_router, tags=["Payments"]) +router.add_router("certificates/", certificates_router, tags=["Certificates"]) +router.add_router("meta/", meta_router, tags=["Meta"]) +router.add_router("/", health_router, tags=["Health"]) diff --git a/backend/api/views/__init__.py b/backend/api/views/__init__.py new file mode 100644 index 0000000..dbd8ada --- /dev/null +++ b/backend/api/views/__init__.py @@ -0,0 +1,9 @@ +from api.views.auth import auth_router +from api.views.blog import blog_router +from api.views.gallery import gallery_router +from api.views.events import events_router +from api.views.certificates import certificates_router +from api.views.communications import communications_router +from api.views.payments import payments_router +from api.views.meta import meta_router +from api.views.health import health_router diff --git a/backend/api/views/auth.py b/backend/api/views/auth.py new file mode 100644 index 0000000..f1ad64b --- /dev/null +++ b/backend/api/views/auth.py @@ -0,0 +1,397 @@ +from typing import List + +from django.conf import settings +from django.contrib.auth import authenticate +from django.db.models import Q +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.core.files.storage import default_storage +from django.core.files.base import ContentFile + +import uuid +import jwt +from ninja import Query, Router + +from users.models import User, Major, University +from users.tasks import send_verification_email, send_password_reset_email +from api.authentication import create_jwt_token, create_refresh_token, jwt_auth +from api.schemas import ( + UserRegistrationSchema, UserLoginSchema, UserProfileSchema, + UserUpdateSchema, TokenSchema, TokenRefreshIn, MessageSchema, ErrorSchema, + PasswordResetRequestSchema, PasswordResetConfirmSchema, UsernameCheckSchema, + UserListSchema +) + +auth_router = Router() + +def _get_major_from_code(code: str | None): + if not code: + return None + return Major.objects.filter(code=code, is_deleted=False).first() + + +def _get_university_from_code(code: str | None): + if not code: + return None + return University.objects.filter(code=code, is_deleted=False).first() + + +@auth_router.post("/register", response={201: MessageSchema, 400: ErrorSchema}) +def register(request, data: UserRegistrationSchema): + """Register a new user""" + try: + if data.student_id and len(str(data.student_id)) < 10: + return 400, {"error": "Student ID must be at least 10 characters long."} + + major_obj = None + if data.major: + major_obj = _get_major_from_code(data.major) + if not major_obj: + return 400, {"error": "Selected major is not recognized."} + + university_obj = None + if data.university: + university_obj = _get_university_from_code(data.university) + if not university_obj: + return 400, {"error": "Selected university is not recognized."} + + if User.objects.filter(username=data.username).exists(): + return 400, {"error": "Username is already in use."} + + if User.objects.filter(email=data.email).exists(): + return 400, {"error": "Email is already registered."} + + if ( + data.student_id + and university_obj + and User.objects.filter( + university=university_obj, student_id=data.student_id + ).exists() + ): + return 400, {"error": "This student ID is already registered at that university."} + + User.objects.create_user( + username=data.username, + email=data.email, + password=data.password, + student_id=data.student_id, + first_name=data.first_name or "", + last_name=data.last_name or "", + year_of_study=data.year_of_study, + major=major_obj, + university=university_obj, + ) + + return 201, {"message": "Registration successful. Please check your inbox to verify your email."} + + except Exception as e: + return 400, { + "error": "Unable to register user.", + "details": str(e), + } + +@auth_router.post("/login", response={200: TokenSchema, 401: ErrorSchema}) +def login(request, data: UserLoginSchema): + """Login user and return JWT tokens""" + user = authenticate(email=data.email, password=data.password) + + if not user: + return 401, {"error": "ایمیل یا رمز عبور نادرست است."} + + if not user.is_email_verified: + return 401, {"error": "برای ورود، ابتدا ایمیل خود را تأیید کنید."} + + if not user.is_active: + return 401, {"error": "حساب کاربری شما غیرفعال است."} + + access_token = create_jwt_token(user) + refresh_token = create_refresh_token(user) + + return 200, { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer" + } + +@auth_router.post("/refresh", response={200: TokenSchema, 401: ErrorSchema}) +def refresh_tokens(request, data: TokenRefreshIn): + """Exchange a valid refresh token for a new access (and refresh) token.""" + try: + payload = jwt.decode( + data.refresh_token, + settings.JWT_SECRET_KEY, + algorithms=[settings.JWT_ALGORITHM], + ) + if payload.get("type") != "refresh": + return 401, {"error": "نوع توکن نامعتبر است."} + + user_id = payload.get("user_id") + if not user_id: + return 401, {"error": "داده‌های توکن نامعتبر است."} + + user = get_object_or_404(User, id=user_id) + + if not user.is_email_verified: + return 401, {"error": "برای استفاده، ابتدا ایمیل خود را تأیید کنید."} + + if not user.is_active: + return 401, {"error": "حساب کاربری شما غیرفعال است."} + + except jwt.ExpiredSignatureError: + return 401, {"error": "رفرش‌توکن منقضی شده است."} + + except jwt.InvalidTokenError: + return 401, {"error": "رفرش‌توکن نامعتبر است."} + + access_token = create_jwt_token(user) + refresh_token = create_refresh_token(user) + + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + } + +@auth_router.get("/verify-email/{token}", response={200: MessageSchema, 400: ErrorSchema}) +def verify_email(request, token: str): + """Verify user email with token""" + try: + user = get_object_or_404(User, email_verification_token=token) + + if user.is_email_verified: + return 400, {"error": "ایمیل قبلاً تأیید شده است."} + + user.is_email_verified = True + user.save(update_fields=['is_email_verified']) + + return 200, {"message": "ایمیل شما با موفقیت تأیید شد."} + + except User.DoesNotExist: + return 400, {"error": "توکن تأیید نامعتبر است."} + +@auth_router.post("/resend-verification", response={200: MessageSchema, 400: ErrorSchema}) +def resend_verification(request, email: str): + """Resend verification email""" + try: + user = get_object_or_404(User, email=email) + + if user.is_email_verified: + return 400, {"error": "ایمیل قبلاً تأیید شده است."} + + # Generate new token + user.regenerate_verification_token() + user.email_verification_sent_at = timezone.now() + user.save(update_fields=['email_verification_sent_at']) + + # Send verification email + verification_url = f"{settings.FRONTEND_ROOT}verify-email/{user.email_verification_token}" + send_verification_email.delay(user.id, verification_url) + + return 200, {"message": "ایمیل تأیید برای شما ارسال شد."} + + except User.DoesNotExist: + return 400, {"error": "کاربر یافت نشد."} + +@auth_router.get("/profile", response=UserProfileSchema, auth=jwt_auth) +def get_profile(request): + """Get current user profile""" + return request.auth + +@auth_router.put("/profile", response={200: UserProfileSchema, 400: ErrorSchema}, auth=jwt_auth) +def update_profile(request, data: UserUpdateSchema): + """Update current user profile""" + user = request.auth + payload = data.dict(exclude_unset=True) + + if "major" in payload: + code = payload.pop("major") + if code: + major_obj = _get_major_from_code(code) + if not major_obj: + return 400, {"error": "UcO_ O�OrU?UOU? O�U^UcU+ O�O�UOUOO_."} + payload["major"] = major_obj + else: + payload["major"] = None + + if "university" in payload: + code = payload.pop("university") + if code: + uni_obj = _get_university_from_code(code) + if not uni_obj: + return 400, {"error": "UcO U.U^OO�O_ O�U^UcU+ O�O�UOUOO_."} + payload["university"] = uni_obj + else: + payload["university"] = None + + for field, value in payload.items(): + setattr(user, field, value) + + user.save() + return 200, user + +@auth_router.post("/profile/picture", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) +def upload_profile_picture(request): + """Upload profile picture""" + if 'file' not in request.FILES: + return 400, {"error": "فایلی ارسال نشده است."} + + file = request.FILES['file'] + + # Validate file type + if not file.content_type.startswith('image/'): + return 400, {"error": "فایل باید از نوع تصویر باشد."} + + # Validate file size (5MB max) + if file.size > 5 * 1024 * 1024: + return 400, {"error": "حجم فایل باید کمتر از ۵ مگابایت باشد."} + + user = request.auth + + # Delete old profile picture if exists + if user.profile_picture: + default_storage.delete(user.profile_picture.name) + + # Save new profile picture + filename = f"profile_pictures/{user.id}_{uuid.uuid4().hex}.{file.name.split('.')[-1]}" + user.profile_picture.save(filename, ContentFile(file.read())) + + return 200, {"message": "تصویر پروفایل با موفقیت به‌روزرسانی شد."} + +@auth_router.delete("/profile/picture", response={200: MessageSchema}, auth=jwt_auth) +def delete_profile_picture(request): + """Delete current user's profile picture""" + user = request.auth + + if user.profile_picture: + default_storage.delete(user.profile_picture.name) + user.profile_picture = None + user.save(update_fields=['profile_picture']) + + return 200, {"message": "تصویر پروفایل با موفقیت حذف شد."} + +@auth_router.post("/request-password-reset", response={200: MessageSchema, 400: ErrorSchema}) +def request_password_reset(request, data: PasswordResetRequestSchema): + """Request a password reset email""" + try: + user = get_object_or_404(User, email=data.email) + user.set_password_reset_token() + + reset_url = f"{settings.FRONTEND_PASSWORD_RESET_PAGE}/{user.password_reset_token}" + send_password_reset_email.delay(user.id, reset_url) + + # پیام عمومیِ یکسان برای جلوگیری از افشای وجود/عدم وجود ایمیل + return 200, {"message": "اگر حسابی با این ایمیل وجود داشته باشد، لینک بازنشانی رمز عبور ارسال خواهد شد."} + + except User.DoesNotExist: + return 200, {"message": "اگر حسابی با این ایمیل وجود داشته باشد، لینک بازنشانی رمز عبور ارسال خواهد شد."} + + except Exception as e: + return 400, {"error": "درخواست بازنشانی رمز عبور انجام نشد.", "details": str(e)} + +@auth_router.post("/reset-password-confirm", response={200: MessageSchema, 400: ErrorSchema}) +def reset_password_confirm(request, data: PasswordResetConfirmSchema): + """Confirm password reset with token and new password""" + try: + user = get_object_or_404(User, password_reset_token=data.token) + + if user.password_reset_token_expires_at < timezone.now(): + user.password_reset_token = None + user.password_reset_token_expires_at = None + user.save(update_fields=['password_reset_token', 'password_reset_token_expires_at']) + return 400, {"error": "زمان استفاده از لینک تغییر رمز عبور به پایان رسیده است. لطفاً دوباره اقدام کنید."} + + user.set_password(data.new_password) + user.password_reset_token = None + user.password_reset_token_expires_at = None + user.save(update_fields=['password', 'password_reset_token', 'password_reset_token_expires_at']) + + return 200, {"message": "رمز عبور شما با موفقیت تغییر کرد."} + + except User.DoesNotExist: + return 400, {"error": "توکن بازنشانی رمز عبور نامعتبر یا منقضی شده است."} + + except Exception as e: + return 400, {"error": "تغییر رمز عبور انجام نشد.", "details": str(e)} + +@auth_router.get("/users/deleted", response={200: List[UserProfileSchema], 403: ErrorSchema}, auth=jwt_auth) +def list_deleted_users(request): + """List soft-deleted users via the dedicated manager (Admin/Committee only).""" + if not (request.auth.is_staff or request.auth.is_superuser): + return 403, {"error": "اجازه دسترسی ندارید."} + + return User.deleted_objects.all() + +@auth_router.post("/users/{user_id}/restore", response={200: MessageSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth) +def restore_user(request, user_id: int): + """Restore a soft-deleted user (Admin/Committee only)""" + if not (request.auth.is_staff or request.auth.is_superuser): + return 403, {"error": "اجازه دسترسی ندارید."} + + try: + user = User.deleted_objects.get(id=user_id) + user.restore() + return 200, {"message": f"کاربر {user.username} با موفقیت بازیابی شد."} + except User.DoesNotExist: + return 400, {"error": "کاربر یافت نشد یا حذف نرم نشده است."} + except Exception as e: + return 400, {"error": "بازیابی کاربر انجام نشد.", "details": str(e)} + +@auth_router.get("/users", response={200: List[UserListSchema], 403: ErrorSchema}, auth=jwt_auth) +def list_users( + request, + search: str | None = Query(None), + role: str | None = Query(None, description="staff or superuser"), + student_id: str | None = Query(None), + university: str | None = Query(None), + major: str | None = Query(None), + is_active: str | None = Query(None, description="true or false"), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), +): + user = request.auth + if not (user.is_staff or user.is_superuser): + return 403, {"error": "اجازه دسترسی ندارید."} + + queryset = User.objects.order_by("-date_joined") + + if search: + queryset = queryset.filter( + Q(username__icontains=search) + | Q(email__icontains=search) + | Q(first_name__icontains=search) + | Q(last_name__icontains=search) + ) + + if role == "staff": + queryset = queryset.filter(is_staff=True) + elif role == "superuser": + queryset = queryset.filter(is_superuser=True) + + if student_id: + queryset = queryset.filter(student_id__icontains=student_id) + + if university: + queryset = queryset.filter( + Q(university__code__icontains=university) | Q(university__name__icontains=university) + ) + + if major: + queryset = queryset.filter( + Q(major__code__icontains=major) | Q(major__name__icontains=major) + ) + + if is_active is not None: + if is_active.lower() in ("true", "1"): + queryset = queryset.filter(is_active=True) + elif is_active.lower() in ("false", "0"): + queryset = queryset.filter(is_active=False) + + return queryset[offset : offset + limit] + +@auth_router.get("/check-username", response=UsernameCheckSchema) +def check_username_availability(request, username: str): + """Check if a username is available for registration""" + exists = User.objects.filter(username=username).exists() + return {"exists": exists} + + diff --git a/backend/api/views/blog.py b/backend/api/views/blog.py new file mode 100644 index 0000000..1fb1127 --- /dev/null +++ b/backend/api/views/blog.py @@ -0,0 +1,299 @@ +from django.shortcuts import get_object_or_404 +from django.db.models import Q, Prefetch + +from ninja import Router, Query +from typing import List, Optional + +from users.models import User +from blog.models import Post, Category, Tag, Comment, Like +from api.authentication import jwt_auth +from api.schemas import ( + PostListSchema, PostDetailSchema, PostCreateSchema, + CategorySchema, TagSchema, CommentSchema, CommentCreateSchema, + MessageSchema, ErrorSchema +) + +blog_router = Router() + +# Post endpoints +@blog_router.get("/posts", response=List[PostListSchema]) +def list_posts( + request, + page: int = Query(1, ge=1), + limit: int = Query(10, ge=1, le=50), + category: Optional[str] = None, + tag: Optional[str] = None, + search: Optional[str] = None, + featured: Optional[bool] = None, + author: Optional[str] = None +): + """List published posts with filtering and pagination""" + queryset = Post.objects.filter(status=Post.StatusChoices.PUBLISHED).select_related( + 'author', 'category' + ).prefetch_related('tags') + + # Apply filters + if category: + queryset = queryset.filter(category__slug=category) + + if tag: + queryset = queryset.filter(tags__slug=tag) + + if search: + queryset = queryset.filter( + Q(title__icontains=search) | + Q(content__icontains=search) | + Q(excerpt__icontains=search) + ) + + if featured is not None: + queryset = queryset.filter(is_featured=featured) + + if author: + queryset = queryset.filter(author__username=author) + + # Pagination + offset = (page - 1) * limit + posts = queryset[offset:offset + limit] + + return posts + +@blog_router.get("/posts/{slug}", response=PostDetailSchema) +def get_post(request, slug: str): + """Get single post by slug""" + post = get_object_or_404( + Post.objects.select_related('author', 'category').prefetch_related('tags'), + slug=slug, + status=Post.StatusChoices.PUBLISHED + ) + return post + +@blog_router.post("/posts", response={201: PostDetailSchema, 400: ErrorSchema}, auth=jwt_auth) +def create_post(request, data: PostCreateSchema): + """Create a new post (committee members only)""" + user = request.auth + + if not (user.is_superuser or user.is_staff): + return 400, {"error": "Only committee members can create posts"} + + try: + post = Post.objects.create( + title=data.title, + content=data.content, + excerpt=data.excerpt, + author=user, + category_id=data.category_id, + status=data.status, + is_featured=data.is_featured + ) + + if data.tag_ids: + post.tags.set(data.tag_ids) + + return 201, post + + except Exception as e: + return 400, {"error": "Failed to create post", "details": str(e)} + +@blog_router.put("/posts/{slug}", response={200: PostDetailSchema, 400: ErrorSchema}, auth=jwt_auth) +def update_post(request, slug: str, data: PostCreateSchema): + """Update a post (author or committee only)""" + user = request.auth + post = get_object_or_404(Post, slug=slug) + + if not (post.author == user or user.is_superuser or user.is_staff): + return 400, {"error": "You can only edit your own posts"} + + try: + for field, value in data.dict(exclude_unset=True).items(): + if field == 'tag_ids': + if value: + post.tags.set(value) + elif field == 'category_id': + post.category_id = value + else: + setattr(post, field, value) + + post.save() + return 200, post + + except Exception as e: + return 400, {"error": "Failed to update post", "details": str(e)} + +@blog_router.delete("/posts/{slug}", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) +def delete_post(request, slug: str): + """Soft delete a post owned by the requester or committee.""" + user = request.auth + post = get_object_or_404(Post, slug=slug) + + if not (post.author == user or user.is_superuser or user.is_staff): + return 400, {"error": "You can only delete your own posts"} + + post.delete() + return 200, {"message": "Post deleted successfully"} + +@blog_router.get("/deleted/posts", response=List[PostListSchema], auth=jwt_auth) +def list_deleted_posts(request): + """List all soft-deleted posts (Admin/Committee only)""" + if not (request.auth.is_staff or request.auth.is_superuser): + return 403, {"error": "Permission denied"} + return Post.deleted_objects.all().select_related('author', 'category').prefetch_related('tags') + +@blog_router.post("deleted/posts/{post_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) +def restore_post(request, post_id: int): + """Restore a soft-deleted post (Admin/Committee only)""" + if not (request.auth.is_staff or request.auth.is_superuser): + return 403, {"error": "Permission denied"} + try: + post = Post.deleted_objects.get(id=post_id) + post.restore() + return 200, {"message": f"Post '{post.title}' restored successfully."} + except Post.DoesNotExist: + return 400, {"error": "Post not found or not soft-deleted."} + + + +# Comment endpoints +@blog_router.get("/posts/{slug}/comments", response=List[CommentSchema]) +def list_comments(request, slug: str): + """List approved comments for a post""" + post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) + + comments = Comment.objects.filter( + post=post, + is_approved=True, + parent=None + ).select_related('author').prefetch_related( + Prefetch( + 'replies', + queryset=Comment.objects.filter(is_approved=True).select_related('author') + ) + ) + + return comments + +@blog_router.post("/posts/{slug}/comments", response={201: CommentSchema, 400: ErrorSchema}, auth=jwt_auth) +def create_comment(request, slug: str, data: CommentCreateSchema): + """Create a comment on a post""" + post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) + user = request.auth + + try: + comment = Comment.objects.create( + post=post, + author=user, + content=data.content, + parent_id=data.parent_id + ) + + return 201, comment + + except Exception as e: + return 400, {"error": "Failed to create comment", "details": str(e)} + +@blog_router.get("/deleted/comments", response=List[CommentSchema], auth=jwt_auth) +def list_deleted_comments(request): + """List all soft-deleted comments (Admin/Committee only)""" + if not (request.auth.is_staff or request.auth.is_superuser): + return 403, {"error": "Permission denied"} + return Comment.deleted_objects.all().select_related('author', 'post') + +@blog_router.post("/deleted/comments/{comment_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) +def restore_comment(request, comment_id: int): + """Restore a soft-deleted comment (Admin/Committee only)""" + if not (request.auth.is_staff or request.auth.is_superuser): + return 403, {"error": "Permission denied"} + try: + comment = Comment.deleted_objects.get(id=comment_id) + comment.restore() + return 200, {"message": f"Comment by {comment.author.username} restored successfully."} + except Comment.DoesNotExist: + return 400, {"error": "Comment not found or not soft-deleted."} + + + +# Like endpoints +@blog_router.post("/posts/{slug}/like", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) +def toggle_like(request, slug: str): + """Toggle like on a post""" + post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) + user = request.auth + + like, created = Like.objects.get_or_create(post=post, user=user) + + if not created: + like.delete() + return 200, {"message": "Post unliked"} + + return 200, {"message": "Post liked"} + +@blog_router.get("/posts/{slug}/likes", response={200: MessageSchema}) +def get_likes_count(request, slug: str): + """Get likes count for a post""" + post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED) + count = post.likes.count() + return {"message": f"{count}"} + + + +# Category endpoints +@blog_router.get("/categories", response=List[CategorySchema]) +def list_categories(request): + """List all categories""" + return Category.objects.all() + +@blog_router.get("/categories/{slug}", response=CategorySchema) +def get_category(request, slug: str): + """Get single category by slug""" + return get_object_or_404(Category, slug=slug) + +@blog_router.get("/deleted/categories", response=List[CategorySchema], auth=jwt_auth) +def list_deleted_categories(request): + """List all soft-deleted categories (Admin/Committee only)""" + if not (request.auth.is_staff or request.auth.is_superuser): + return 403, {"error": "Permission denied"} + return Category.deleted_objects.all() + +@blog_router.post("/deleted/categories/{category_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) +def restore_category(request, category_id: int): + """Restore a soft-deleted category (Admin/Committee only)""" + if not (request.auth.is_staff or request.auth.is_superuser): + return 403, {"error": "Permission denied"} + try: + category = Category.deleted_objects.get(id=category_id) + category.restore() + return 200, {"message": f"Category '{category.name}' restored successfully."} + except Category.DoesNotExist: + return 400, {"error": "Category not found or not soft-deleted."} + + + +# Tag endpoints +@blog_router.get("/tags", response=List[TagSchema]) +def list_tags(request): + """List all tags""" + return Tag.objects.all() + +@blog_router.get("/tags/{slug}", response=TagSchema) +def get_tag(request, slug: str): + """Get single tag by slug""" + return get_object_or_404(Tag, slug=slug) + +@blog_router.get("/deleted/tags", response=List[TagSchema], auth=jwt_auth) +def list_deleted_tags(request): + """List all soft-deleted tags (Admin/Committee only)""" + if not (request.auth.is_staff or request.auth.is_superuser): + return 403, {"error": "Permission denied"} + return Tag.all_objects.all() + +@blog_router.post("/deleted/tags/{tag_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) +def restore_tag(request, tag_id: int): + """Restore a soft-deleted tag (Admin/Committee only)""" + if not (request.auth.is_staff or request.auth.is_superuser): + return 403, {"error": "Permission denied"} + try: + tag = Tag.deleted_objects.get(id=tag_id) + tag.restore() + return 200, {"message": f"Tag '{tag.name}' restored successfully."} + except Tag.DoesNotExist: + return 400, {"error": "Tag not found or not soft-deleted."} diff --git a/backend/api/views/certificates.py b/backend/api/views/certificates.py new file mode 100644 index 0000000..84b9472 --- /dev/null +++ b/backend/api/views/certificates.py @@ -0,0 +1,138 @@ +from django.shortcuts import get_object_or_404 +from django.core.exceptions import ValidationError + +from ninja import Router +from ninja.errors import HttpError + +from api.authentication import jwt_auth +from api.schemas.certificates import ( + CertificateTemplateOut, + CertificateGenerationPayload, + CertificateGenerationResponse, + CertificateVerificationOut, + SkillSchema, + UserCertificateOut, +) +from certificates.models import CertificateTemplate, UserCertificate + + +certificates_router = Router(tags=["Certificates"]) + + +def _ensure_staff(user): + if not user or not user.is_staff: + raise HttpError(403, "Only staff users can access certificate management.") + + +@certificates_router.get( + "templates/{int:event_id}", + response=CertificateTemplateOut, + auth=jwt_auth, +) +def get_template(request, event_id: int): + _ensure_staff(request.auth) + template = get_object_or_404( + CertificateTemplate.objects.select_related('event').prefetch_related('skills'), + event_id=event_id, + is_deleted=False, + ) + + skills = [ + SkillSchema( + id=skill.id, + name=skill.name, + description=skill.description, + ) + for skill in template.skills.all() + ] + + image_url = None + if template.image and hasattr(template.image, 'url'): + image_url = request.build_absolute_uri(template.image.url) + + return CertificateTemplateOut( + id=template.id, + event_id=template.event_id, + event_title=template.event.title, + image_url=image_url, + skill_ids=list(template.skills.values_list('id', flat=True)), + skills=skills, + ) + + +@certificates_router.post( + "templates/{int:event_id}/generate", + response=CertificateGenerationResponse, + auth=jwt_auth, +) +def generate_certificates(request, event_id: int, payload: CertificateGenerationPayload): + _ensure_staff(request.auth) + template = get_object_or_404( + CertificateTemplate.objects.select_related('event').prefetch_related('skills'), + event_id=event_id, + is_deleted=False, + ) + + try: + entries = [entry.model_dump() for entry in payload.entries] + certificates = template.generate_certificates( + entries, + default_title=payload.default_title, + default_description=payload.default_description, + ) + except ValidationError as exc: + raise HttpError(400, str(exc)) + + result = [] + for certificate in certificates: + image_url = None + if certificate.image and hasattr(certificate.image, 'url'): + image_url = request.build_absolute_uri(certificate.image.url) + + result.append( + UserCertificateOut( + id=certificate.id, + user_id=certificate.user_id, + user_name=certificate.user.get_full_name() or certificate.user.email, + event_id=certificate.event_id, + title=certificate.title, + certificate_id=str(certificate.certificate_id), + certificate_code=certificate.code, + score=certificate.score, + score_label=certificate.score_label, + image_url=image_url, + ) + ) + + return CertificateGenerationResponse(certificates=result) + + +@certificates_router.get( + "verify/{str:certificate_code}", + response=CertificateVerificationOut, +) +def verify_certificate(request, certificate_code): + certificate = get_object_or_404( + UserCertificate.objects.select_related('event', 'user').prefetch_related('skills'), + code=certificate_code, + is_deleted=False, + ) + image_url = None + if certificate.image and hasattr(certificate.image, 'url'): + image_url = request.build_absolute_uri(certificate.image.url) + + return CertificateVerificationOut( + certificate_id=str(certificate.certificate_id), + certificate_code=certificate.code, + user_id=certificate.user_id, + user_name=certificate.user.get_full_name() or certificate.user.email, + event_id=certificate.event_id, + event_title=certificate.event.title, + title=certificate.title, + score=certificate.score, + score_label=certificate.score_label, + issued_at=certificate.issued_at, + expires_at=certificate.expires_at, + image_url=image_url, + skills=[skill.name for skill in certificate.skills.all()], + ) diff --git a/backend/api/views/communications.py b/backend/api/views/communications.py new file mode 100644 index 0000000..03e5b9c --- /dev/null +++ b/backend/api/views/communications.py @@ -0,0 +1,329 @@ +from django.shortcuts import get_object_or_404 +from django.contrib.auth import get_user_model +from django.utils import timezone +from django.db.models import Q, Count +from ninja import Router +from ninja.pagination import paginate +from typing import List +import logging + +from communications.models import ( + Announcement, NewsletterSubscription, PushNotificationDevice, + AnnouncementType, AnnouncementPriority +) +from communications.utils import ( + send_announcement_email, send_newsletter_confirmation, + get_announcement_recipients +) +from communications.push_notifications import push_service +from api.schemas import ( + AnnouncementSchema, AnnouncementListSchema, AnnouncementCreateSchema, AnnouncementUpdateSchema, + NewsletterSubscriptionSchema, NewsletterSubscribeSchema, NewsletterUnsubscribeSchema, + PushDeviceSchema, PushDeviceCreateSchema, PushDeviceUpdateSchema, + PushNotificationSchema, MessageResponseSchema, + AnnouncementStatsSchema, NewsletterStatsSchema +) +from api.authentication import jwt_auth + +User = get_user_model() +logger = logging.getLogger(__name__) + +communications_router = Router() + +# Announcement endpoints +@communications_router.get("/announcements/", response=List[AnnouncementListSchema]) +@paginate +def list_announcements(request, published_only: bool = True): + """List announcements""" + queryset = Announcement.objects.select_related('author').filter(is_deleted=False) + + if published_only: + queryset = queryset.filter(is_published=True, publish_date__lte=timezone.now()) + + return queryset.order_by('-created_at') + +@communications_router.get("/announcements/{announcement_id}/", response=AnnouncementSchema) +def get_announcement(request, announcement_id: int): + """Get single announcement""" + announcement = get_object_or_404( + Announcement.objects.select_related('author').filter(is_deleted=False), + id=announcement_id + ) + + # Check if published or user has permission + if not announcement.is_published: + # Only allow access to unpublished announcements for staff/committee + if not hasattr(request, 'auth') or not request.auth: + return {"error": "Announcement not found"}, 404 + + user = request.auth + if not (user.is_staff or user.is_committee): + return {"error": "Announcement not found"}, 404 + + return announcement + +@communications_router.post("/announcements/", response=AnnouncementSchema, auth=jwt_auth) +def create_announcement(request, payload: AnnouncementCreateSchema): + """Create new announcement (committee/staff only)""" + user = request.auth + if not (user.is_staff or user.is_committee): + return {"error": "Permission denied"}, 403 + + announcement = Announcement.objects.create( + author=user, + **payload.dict() + ) + + # Send notifications if requested and published + if announcement.is_published and announcement.publish_date <= timezone.now(): + if announcement.send_email: + recipients = get_announcement_recipients(announcement) + if recipients: + send_announcement_email(announcement, recipients) + announcement.email_sent = True + + if announcement.send_push: + push_service.send_announcement_notification(announcement) + announcement.push_sent = True + + announcement.save() + + return announcement + +@communications_router.put("/announcements/{announcement_id}/", response=AnnouncementSchema, auth=jwt_auth) +def update_announcement(request, announcement_id: int, payload: AnnouncementUpdateSchema): + """Update announcement (author/committee/staff only)""" + user = request.auth + announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False) + + # Check permissions + if not (user.is_staff or user.is_committee or announcement.author == user): + return {"error": "Permission denied"}, 403 + + # Update fields + for field, value in payload.dict(exclude_unset=True).items(): + setattr(announcement, field, value) + + announcement.save() + + # Send notifications if newly published + if (announcement.is_published and announcement.publish_date <= timezone.now() and + not announcement.email_sent and announcement.send_email): + recipients = get_announcement_recipients(announcement) + if recipients: + send_announcement_email(announcement, recipients) + announcement.email_sent = True + announcement.save() + + if (announcement.is_published and announcement.publish_date <= timezone.now() and + not announcement.push_sent and announcement.send_push): + push_service.send_announcement_notification(announcement) + announcement.push_sent = True + announcement.save() + + return announcement + +@communications_router.delete("/announcements/{announcement_id}/", response=MessageResponseSchema, auth=jwt_auth) +def delete_announcement(request, announcement_id: int): + """Delete announcement (author/committee/staff only)""" + user = request.auth + announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False) + + # Check permissions + if not (user.is_staff or user.is_committee or announcement.author == user): + return {"error": "Permission denied"}, 403 + + announcement.soft_delete() + return {"message": "Announcement deleted successfully"} + +@communications_router.get("/announcements/stats/", response=AnnouncementStatsSchema, auth=jwt_auth) +def get_announcement_stats(request): + """Get announcement statistics (committee/staff only)""" + user = request.auth + if not (user.is_staff or user.is_committee): + return {"error": "Permission denied"}, 403 + + stats = Announcement.objects.filter(is_deleted=False).aggregate( + total_announcements=Count('id'), + published_announcements=Count('id', filter=Q(is_published=True)), + draft_announcements=Count('id', filter=Q(is_published=False)), + urgent_announcements=Count('id', filter=Q(priority='urgent')), + email_sent_count=Count('id', filter=Q(email_sent=True)), + push_sent_count=Count('id', filter=Q(push_sent=True)) + ) + + return stats + +# Newsletter endpoints +@communications_router.post("/newsletter/subscribe/", response=MessageResponseSchema) +def subscribe_newsletter(request, payload: NewsletterSubscribeSchema): + """Subscribe to newsletter""" + try: + subscription, created = NewsletterSubscription.objects.get_or_create( + email=payload.email, + defaults={ + 'subscribed_categories': payload.subscribed_categories, + 'is_active': True + } + ) + + if not created and not subscription.is_active: + subscription.is_active = True + subscription.subscribed_categories = payload.subscribed_categories + subscription.save() + + # Send confirmation email + send_newsletter_confirmation(subscription) + + message = ( + "عضویت در خبرنامه با موفقیت انجام شد! لطفاً برای تأیید، ایمیل خود را بررسی کنید." + if created + else "اشتراک خبرنامه به‌روزرسانی شد!" + ) + return {"message": message} + + except Exception as e: + logger.error(f"Newsletter subscription failed: {str(e)}") + return {"message": "Subscription failed", "success": False}, 400 + +@communications_router.post("/newsletter/unsubscribe/", response=MessageResponseSchema) +def unsubscribe_newsletter(request, payload: NewsletterUnsubscribeSchema): + """Unsubscribe from newsletter""" + try: + subscription = NewsletterSubscription.objects.get(email=payload.email) + subscription.is_active = False + subscription.save() + return {"message": "Successfully unsubscribed from newsletter"} + except NewsletterSubscription.DoesNotExist: + return {"message": "Email not found in subscription list"}, 404 + +@communications_router.get("/newsletter/confirm/{token}/", response=MessageResponseSchema) +def confirm_newsletter_subscription(request, token: str): + """Confirm newsletter subscription""" + try: + subscription = NewsletterSubscription.objects.get(confirmation_token=token) + subscription.confirmed_at = timezone.now() + subscription.is_active = True + subscription.save() + return {"message": "Newsletter subscription confirmed successfully!"} + except NewsletterSubscription.DoesNotExist: + return {"message": "Invalid confirmation token"}, 400 + +@communications_router.get("/newsletter/unsubscribe/{token}/", response=MessageResponseSchema) +def unsubscribe_newsletter_token(request, token: str): + """Unsubscribe using token from email""" + try: + subscription = NewsletterSubscription.objects.get(unsubscribe_token=token) + subscription.is_active = False + subscription.save() + return {"message": "Successfully unsubscribed from newsletter"} + except NewsletterSubscription.DoesNotExist: + return {"message": "Invalid unsubscribe token"}, 400 + +@communications_router.get("/newsletter/subscriptions/", response=List[NewsletterSubscriptionSchema], auth=jwt_auth) +@paginate +def list_newsletter_subscriptions(request): + """List newsletter subscriptions (committee/staff only)""" + user = request.auth + if not (user.is_staff or user.is_committee): + return {"error": "Permission denied"}, 403 + + return NewsletterSubscription.objects.select_related('user').filter(is_deleted=False).order_by('-created_at') + +@communications_router.get("/newsletter/stats/", response=NewsletterStatsSchema, auth=jwt_auth) +def get_newsletter_stats(request): + """Get newsletter statistics (committee/staff only)""" + user = request.auth + if not (user.is_staff or user.is_committee): + return {"error": "Permission denied"}, 403 + + stats = NewsletterSubscription.objects.filter(is_deleted=False).aggregate( + total_subscriptions=Count('id'), + active_subscriptions=Count('id', filter=Q(is_active=True)), + confirmed_subscriptions=Count('id', filter=Q(confirmed_at__isnull=False)), + recent_subscriptions=Count('id', filter=Q(created_at__gte=timezone.now() - timezone.timedelta(days=30))) + ) + + return stats + +# Push notification endpoints +@communications_router.post("/push-devices/", response=PushDeviceSchema, auth=jwt_auth) +def register_push_device(request, payload: PushDeviceCreateSchema): + """Register push notification device""" + user = request.auth + + device, created = PushNotificationDevice.objects.get_or_create( + user=user, + device_token=payload.device_token, + defaults={'device_type': payload.device_type, 'is_active': True} + ) + + if not created: + device.is_active = True + device.device_type = payload.device_type + device.save() + + return device + +@communications_router.delete("/push-devices/", response=MessageResponseSchema, auth=jwt_auth) +def unregister_push_device(request, device_token: str): + """Unregister push notification device""" + user = request.auth + + try: + device = PushNotificationDevice.objects.get(user=user, device_token=device_token) + device.delete() + return {"message": "Device unregistered successfully"} + except PushNotificationDevice.DoesNotExist: + return {"message": "Device not found"}, 404 + +@communications_router.get("/push-devices/", response=List[PushDeviceSchema], auth=jwt_auth) +def list_user_push_devices(request): + """List user's push notification devices""" + user = request.auth + return PushNotificationDevice.objects.filter(user=user, is_deleted=False).order_by('-created_at') + +@communications_router.put("/push-devices/{device_id}/", response=PushDeviceSchema, auth=jwt_auth) +def update_push_device(request, device_id: int, payload: PushDeviceUpdateSchema): + """Update push notification device""" + user = request.auth + device = get_object_or_404(PushNotificationDevice, id=device_id, user=user, is_deleted=False) + + device.is_active = payload.is_active + device.save() + + return device + +@communications_router.post("/push-notifications/send/", response=MessageResponseSchema, auth=jwt_auth) +def send_push_notification(request, payload: PushNotificationSchema): + """Send push notification (committee/staff only)""" + user = request.auth + if not (user.is_staff or user.is_committee): + return {"error": "Permission denied"}, 403 + + # Get target users + users = [] + if payload.target_audience == 'all': + users = User.objects.filter(is_active=True) + elif payload.target_audience == 'members': + users = User.objects.filter(is_member=True, is_active=True) + elif payload.target_audience == 'committee': + users = User.objects.filter(is_committee=True, is_active=True) + + # Send notifications + total_sent = push_service.send_to_multiple_users( + users, payload.title, payload.body, payload.data + ) + + return {"message": f"Push notification sent to {total_sent} devices"} + +# Utility endpoints +@communications_router.get("/announcement-types/", response=List[dict]) +def get_announcement_types(request): + """Get available announcement types""" + return [{"value": choice[0], "label": choice[1]} for choice in AnnouncementType.choices] + +@communications_router.get("/announcement-priorities/", response=List[dict]) +def get_announcement_priorities(request): + """Get available announcement priorities""" + return [{"value": choice[0], "label": choice[1]} for choice in AnnouncementPriority.choices] diff --git a/backend/api/views/events.py b/backend/api/views/events.py new file mode 100644 index 0000000..191dadc --- /dev/null +++ b/backend/api/views/events.py @@ -0,0 +1,371 @@ +from django.shortcuts import get_object_or_404 +from django.db.models import Q, Case, When, IntegerField +from django.utils.text import slugify +from django.utils import timezone + +from ninja import Router, Query +from ninja.errors import HttpError +from typing import List, Optional +from uuid import UUID + +from api.authentication import jwt_auth +from events.models import Event, Registration +from payments.models import DiscountCode +from api.schemas import ( + EventSchema, + EventCreateSchema, + EventUpdateSchema, + EventListSchema, + RegistrationSchema, + RegistrationStatusUpdateSchema, + RegisterationDetailSchema, + MyEventRegistrationOut, + RegistrationStatusOut, + EventBriefSchema, + EventAdminDetailSchema, + PaginatedRegistrationSchema, + MessageSchema, + ErrorSchema, + RegistrationCreateSchema, +) + +events_router = Router() + +# Event endpoints +@events_router.get("/", response=List[EventListSchema]) +def list_events( + request, + # status: Optional[str] = None, + status: Optional[List[str]] = Query(None), + event_type: Optional[str] = None, + search: Optional[str] = None, + limit: int = 20, + offset: int = 0 +): + """List events with filtering and pagination""" + queryset = Event.objects.filter(is_deleted=False).prefetch_related('gallery_images') + + if status: + if "," in status: + parts = [s.strip() for s in status.split(",") if s.strip()] + queryset = queryset.filter(status__in=parts) + else: + queryset = queryset.filter(status__in=status) + if event_type: + queryset = queryset.filter(event_type=event_type) + if search: + queryset = queryset.filter( + Q(title__icontains=search) | Q(description__icontains=search) + ) + + queryset = queryset.annotate( + published_first=Case( + When(status='published', then=0), + default=1, + output_field=IntegerField() + ) + ).order_by('published_first', '-start_time', '-id') + + events = queryset[offset:offset + limit] + return events + +@events_router.get("/{int:event_id}", response=EventSchema) +def get_event(request, event_id: int): + """Get event details by ID""" + event = get_object_or_404( + Event.objects.prefetch_related('gallery_images'), + id=event_id, + is_deleted=False + ) + return event + +@events_router.get("/slug/{str:slug}", response=EventSchema) +def get_event_by_slug(request, slug: str): + """Get event details by slug""" + event = get_object_or_404( + Event.objects.prefetch_related('gallery_images'), + slug=slug, + is_deleted=False + ) + return event + +@events_router.post("/", response=EventSchema) +def create_event(request, payload: EventCreateSchema): + """Create a new event""" + gallery_image_ids = payload.dict().pop('gallery_image_ids', []) + event = Event.objects.create(**payload.dict(exclude={'gallery_image_ids'})) + + if gallery_image_ids: + event.gallery_images.set(gallery_image_ids) + + return event + +@events_router.put("/{int:event_id}", response=EventSchema) +def update_event(request, event_id: int, payload: EventUpdateSchema): + """Update an existing event""" + event = get_object_or_404(Event, id=event_id, is_deleted=False) + + update_data = payload.dict(exclude_unset=True) + gallery_image_ids = update_data.pop('gallery_image_ids', None) + + for attr, value in update_data.items(): + setattr(event, attr, value) + + if 'title' in update_data: + event.slug = slugify(event.title) + + event.save() + + if gallery_image_ids is not None: + event.gallery_images.set(gallery_image_ids) + + return event + +@events_router.delete("/{int:event_id}", response=MessageSchema) +def delete_event(request, event_id: int): + """Soft delete an event""" + event = get_object_or_404(Event, id=event_id, is_deleted=False) + event.delete() + return {"message": "Event deleted successfully"} + +# Registration endpoints +@events_router.get("/{int:event_id}/registrations", response=List[RegistrationSchema]) +def list_event_registrations(request, event_id: int, limit: int = 20, offset: int = 0): + """List registrations for a specific event""" + event = get_object_or_404(Event, id=event_id, is_deleted=False) + queryset = event.registrations.filter(is_deleted=False).select_related('user') + + registrations = queryset[offset:offset + limit] + return registrations + + +@events_router.get("/{int:event_id}/admin-registrations", response={200: PaginatedRegistrationSchema, 403: ErrorSchema}, auth=jwt_auth) +def list_event_registrations_admin( + request, + event_id: int, + status: Optional[List[str]] = Query(None), + university: Optional[str] = Query(None), + major: Optional[str] = Query(None), + search: Optional[str] = Query(None), + limit: int = Query(20, ge=1, le=200), + offset: int = Query(0, ge=0), +): + """List registrations with filters for admin dashboard""" + user = request.auth + if not (user.is_staff or user.is_superuser): + return 403, {"error": "اجازه دسترسی ندارید."} + + event = get_object_or_404(Event, id=event_id, is_deleted=False) + qs = ( + event.registrations.filter(is_deleted=False) + .select_related("user") + .prefetch_related("payments__discount_code") + .order_by("-registered_at") + ) + + status_values = status or request.GET.getlist('status') + if status_values: + qs = qs.filter(status__in=status_values) + + if university: + qs = qs.filter( + Q(user__university__code__icontains=university) + | Q(user__university__name__icontains=university) + ) + + if major: + qs = qs.filter( + Q(user__major__code__icontains=major) + | Q(user__major__name__icontains=major) + ) + + if search: + qs = qs.filter( + Q(user__username__icontains=search) + | Q(user__email__icontains=search) + | Q(user__first_name__icontains=search) + | Q(user__last_name__icontains=search) + ) + + total = qs.count() + results = qs[offset : offset + limit] + + return PaginatedRegistrationSchema(count=total, next=None, previous=None, results=list(results)) + +@events_router.post( + "/{int:event_id}/register", + response=RegistrationSchema, + auth=jwt_auth, +) +def register_for_event( + request, + event_id: int, + payload: RegistrationCreateSchema | None = None, +): + """Register current user for an event""" + event = get_object_or_404(Event, id=event_id, is_deleted=False) + user = request.auth + + if Registration.objects.filter(event=event, user=user, status=Registration.StatusChoices.CONFIRMED).exists(): + raise HttpError(400, "شما قبلا در این ایونت ثبت‌نام کرده‌اید.") + + if event.registration_end_date and event.registration_end_date < timezone.now(): + raise HttpError(400, "مهلت ثبت‌نام به پایان رسیده‌است") + + if event.registration_start_date and event.registration_start_date > timezone.now(): + raise HttpError(400, "زمان ثبت‌نام هنوز آغاز نشده است") + + if not event.has_available_slots: + raise HttpError(400, "ظرفیت شرکت‌کنندگان تکمیل است") + + # Create or get existing registration + discount_code = None + if payload and payload.discount_code: + discount_code = payload.discount_code + elif request.GET.get("discount_code"): + discount_code = request.GET.get("discount_code") + + registration, created = Registration.objects.get_or_create( + event=event, + user=user, + status=Registration.StatusChoices.PENDING, + defaults={"final_price": event.price}, + ) + + if registration.status == Registration.StatusChoices.CONFIRMED: + return HttpError(400, "شما قبلا در این ایونت ثبت‌نام کرده‌اید") + + if registration.status == Registration.StatusChoices.CANCELLED: + registration = Registration.objects.create( + event=event, + user=user, + status=Registration.StatusChoices.PENDING, + final_price=event.price, + ) + elif not created and registration.final_price is None: + registration.final_price = event.price + registration.save(update_fields=["final_price"]) + + applied_code = None + discount_amount = 0 + final_price = event.price + fields_to_update = [] + + if discount_code: + applied_code = DiscountCode.objects.filter( + code=discount_code, + applicable_events=event, + is_active=True, + ).first() + if not applied_code: + raise HttpError(400, "UcO_ O�OrU?UOU? U.O1O�O\"O� U+UOO3O�") + final_price, discount_amount = applied_code.calculate_discount(event, user) + registration.discount_code = applied_code + registration.discount_amount = discount_amount + fields_to_update.extend(["discount_code", "discount_amount"]) + + if registration.final_price != final_price: + registration.final_price = final_price + fields_to_update.append("final_price") + + if not event.price or final_price == 0: + registration.status = Registration.StatusChoices.CONFIRMED + fields_to_update.append("status") + + if fields_to_update: + registration.save(update_fields=list(set(fields_to_update))) + + return registration + +@events_router.put("/registrations/{int:registration_id}", response=RegistrationSchema, auth=jwt_auth) +def update_registration_status(request, registration_id: int, payload: RegistrationStatusUpdateSchema): + """Update registration status""" + user = request.auth + + registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False) + registration.status = payload.dict(exclude_unset=True).get('status') + registration.full_clean() + registration.save() + + return registration + +@events_router.delete("/registrations/{int:registration_id}", response=MessageSchema, auth=jwt_auth) +def cancel_registration(request, registration_id: int): + """Cancel a registration""" + user = request.auth + + registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False) + registration.delete() + return {"message": "ثبت‌نام شما لغو شد :("} + +@events_router.get("/registerations/verify/{UUID:ticket_id}", response=RegisterationDetailSchema, auth=jwt_auth) +def verify_my_registration(request, ticket_id: UUID): + try: + reg = Registration.objects.select_related("event").get(ticket_id=ticket_id, user=request.auth) + return { + "event_image": request.build_absolute_uri(reg.event.featured_image.url) if reg.event.featured_image else None, + "event_title": reg.event.title, + "event_type": reg.event.get_event_type_display(), + "ticket_id": reg.ticket_id, + "status": reg.status, + "registered_at": reg.registered_at, + "success_markdown": reg.event.registration_success_markdown, + } + except Registration.DoesNotExist: + raise HttpError(404, "registration not found") + + + +@events_router.get("/my-registrations", response=List[MyEventRegistrationOut], auth=jwt_auth) +def my_registrations(request): + qs = ( + Registration.objects + .filter(user=request.auth) + .select_related("event") + .order_by("-created_at") + ) + out: List[MyEventRegistrationOut] = [] + for r in qs: + out.append( + MyEventRegistrationOut( + id=r.id, + created_at=r.created_at, + status=r.status, + event=EventBriefSchema( + id=r.event.id, + title=r.event.title, + slug=r.event.slug, + start_date=r.event.start_time, + end_date=r.event.end_time, + location=r.event.location, + price=r.event.price, + absolute_image_url=request.build_absolute_uri(r.event.featured_image.url) if r.event.featured_image else None, + ), + ) + ) + return out + +@events_router.get("/{event_id}/is-registered", response=RegistrationStatusOut, auth=jwt_auth) +def is_registered(request, event_id: int): + exists = Registration.objects.filter( + user=request.auth, + event_id=event_id, + status=Registration.StatusChoices.CONFIRMED + ).exists() + return {"is_registered": exists} +@events_router.get("/{int:event_id}/admin-detail", response=EventAdminDetailSchema, auth=jwt_auth) +def event_admin_detail(request, event_id: int): + user = request.auth + if not (user.is_staff or user.is_superuser): + return 403, {"error": "اجازه دسترسی ندارید."} + + event = get_object_or_404( + Event.objects.prefetch_related( + 'gallery_images', + 'registrations__user', + 'registrations__payments__discount_code' + ), + id=event_id, + is_deleted=False, + ) + return event diff --git a/backend/api/views/gallery.py b/backend/api/views/gallery.py new file mode 100644 index 0000000..24cb02a --- /dev/null +++ b/backend/api/views/gallery.py @@ -0,0 +1,127 @@ +from django.shortcuts import get_object_or_404 +from django.core.files.base import ContentFile + +from ninja import Router, Query, File, UploadedFile +from typing import List +import uuid + +from gallery.models import Gallery +from gallery.tasks import process_uploaded_image +from api.authentication import jwt_auth +from api.schemas import GallerySchema, GalleryCreateSchema, MessageSchema, ErrorSchema + +gallery_router = Router() + +@gallery_router.get("/images", response=List[GallerySchema]) +def list_gallery_images( + request, + page: int = Query(1, ge=1), + limit: int = Query(20, ge=1, le=50), + public_only: bool = Query(True) +): + """List gallery images""" + queryset = Gallery.objects.select_related('uploaded_by') + + if public_only: + queryset = queryset.filter(is_public=True) + + # Pagination + offset = (page - 1) * limit + images = queryset[offset:offset + limit] + + return images + +@gallery_router.get("/images/{image_id}", response=GallerySchema) +def get_gallery_image(request, image_id: int): + """Get single gallery image""" + image = get_object_or_404(Gallery, id=image_id, is_public=True) + return image + +@gallery_router.post("/images", response={201: GallerySchema, 400: ErrorSchema}, auth=jwt_auth) +def upload_image(request, file: UploadedFile = File(...), data: GalleryCreateSchema = None): + """Upload image to gallery (committee members only)""" + user = request.auth + + if not (user.is_superuser or user.is_staff): + return 400, {"error": "Only committee members can upload images"} + + # Validate file type + if not file.content_type.startswith('image/'): + return 400, {"error": "File must be an image"} + + # Validate file size (10MB max) + if file.size > 10 * 1024 * 1024: + return 400, {"error": "File size must be less than 10MB"} + + try: + # Create gallery item + gallery_item = Gallery.objects.create( + title=data.title if data else file.name, + description=data.description if data else "", + uploaded_by=user, + alt_text=data.alt_text if data else "", + is_public=data.is_public if data else True + ) + + # Save image + filename = f"gallery/{uuid.uuid4().hex}.{file.name.split('.')[-1]}" + gallery_item.image.save(filename, ContentFile(file.read())) + + # Process image asynchronously + process_uploaded_image.delay(gallery_item.id) + + return 201, gallery_item + + except Exception as e: + return 400, {"error": "Failed to upload image", "details": str(e)} + +@gallery_router.put("/images/{image_id}", response={200: GallerySchema, 400: ErrorSchema}, auth=jwt_auth) +def update_image(request, image_id: int, data: GalleryCreateSchema): + """Update gallery image metadata""" + user = request.auth + image = get_object_or_404(Gallery, id=image_id) + + if not (image.uploaded_by == user or user.is_superuser or user.is_staff): + return 400, {"error": "You can only edit your own images"} + + try: + for field, value in data.dict(exclude_unset=True).items(): + setattr(image, field, value) + + image.save() + return 200, image + + except Exception as e: + return 400, {"error": "Failed to update image", "details": str(e)} + +@gallery_router.delete("/images/{image_id}", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) +def delete_image(request, image_id: int): + """Soft delete a gallery image owned by the requester or committee.""" + user = request.auth + image = get_object_or_404(Gallery, id=image_id) + + if not (image.uploaded_by == user or user.is_superuser or user.is_staff): + return 400, {"error": "You can only delete your own images"} + + image.delete() + return 200, {"message": "Image deleted successfully"} + + +@gallery_router.get("/deleted/images", response=List[GallerySchema], auth=jwt_auth) +def list_deleted_gallery_images(request): + """List all soft-deleted gallery images (Admin/Committee only)""" + if not (request.auth.is_staff or request.auth.is_superuser): + return 403, {"error": "Permission denied"} + return Gallery.deleted_objects.all().select_related('uploaded_by') + +@gallery_router.post("/deleted/images/{image_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth) +def restore_gallery_image(request, image_id: int): + """Restore a soft-deleted gallery image (Admin/Committee only)""" + if not (request.auth.is_staff or request.auth.is_superuser): + return 403, {"error": "Permission denied"} + try: + image = Gallery.deleted_objects.get(id=image_id) + image.restore() + return 200, {"message": f"Gallery image '{image.title}' restored successfully."} + except Gallery.DoesNotExist: + return 400, {"error": "Gallery image not found or not soft-deleted."} diff --git a/backend/api/views/health.py b/backend/api/views/health.py new file mode 100644 index 0000000..448ef80 --- /dev/null +++ b/backend/api/views/health.py @@ -0,0 +1,15 @@ +from ninja import Router + +from django.db import connection +from django.utils import timezone + +health_router = Router() + +@health_router.get("/health") +def health(request): + try: + with connection.cursor() as c: + c.execute("SELECT 1;") + return {"status": "ok", "time": timezone.now().isoformat()} + except Exception as e: + return {"status": "error", "error": str(e)}, 500 diff --git a/backend/api/views/meta.py b/backend/api/views/meta.py new file mode 100644 index 0000000..bf860fa --- /dev/null +++ b/backend/api/views/meta.py @@ -0,0 +1,15 @@ +from ninja import Router + +from users.models import Major, University + +meta_router = Router(tags=['meta']) + +@meta_router.get("/majors") +def list_majors(request): + majors = Major.objects.filter(is_deleted=False, is_active=True).order_by("name") + return [{"id": m.id, "code": m.code, "label": m.name} for m in majors] + +@meta_router.get("/universities") +def list_universities(request): + universities = University.objects.filter(is_deleted=False, is_active=True).order_by("name") + return [{"id": u.id, "code": u.code, "label": u.name} for u in universities] diff --git a/backend/api/views/payments.py b/backend/api/views/payments.py new file mode 100644 index 0000000..3f0b6a9 --- /dev/null +++ b/backend/api/views/payments.py @@ -0,0 +1,240 @@ +from django.conf import settings +from django.shortcuts import redirect, get_object_or_404 +from django.utils import timezone + +from ninja import Router +from ninja.errors import HttpError +import requests + +from payments.models import Payment, DiscountCode +from events.models import Event, Registration +from api.authentication import jwt_auth +from api.schemas.payments import CouponVerifyIn, CouponVerifyOut, CreatePaymentIn, CreatePaymentOut, PaymentDetailOut + +payments_router = Router(tags=["Payments"]) + + +@payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth) +def create_payment(request, payload: CreatePaymentIn): + event = get_object_or_404(Event, pk=payload.event_id) + + if Payment.objects.filter(status=Payment.OrderStatusChoices.PAID, user=request.auth, event=event).exists(): + raise HttpError(400, "You have already registered in this event") + + registration = ( + Registration.objects.filter(event=event, user=request.auth, is_deleted=False) + .order_by("-registered_at") + .first() + ) + if not registration or registration.status == Registration.StatusChoices.CANCELLED: + registration = Registration.objects.create( + event=event, + user=request.auth, + status=Registration.StatusChoices.PENDING, + final_price=event.price, + ) + elif registration.final_price is None: + registration.final_price = event.price + registration.save(update_fields=["final_price"]) + + discount_code = None + discount_amount = 0 + final_amount = event.price + + if payload.discount_code: + discount_code = DiscountCode.objects.filter(code=payload.discount_code, applicable_events=event, is_active=True).first() + + if discount_code: + final_amount, discount_amount = discount_code.calculate_discount(event, request.auth) + + registration_updates = [] + if discount_code and registration.discount_code_id != discount_code.id: + registration.discount_code = discount_code + registration_updates.append("discount_code") + if registration.discount_amount != discount_amount: + registration.discount_amount = discount_amount + registration_updates.append("discount_amount") + if registration.final_price != final_amount: + registration.final_price = final_amount + registration_updates.append("final_price") + + if final_amount == 0: + if registration.status != Registration.StatusChoices.CONFIRMED: + registration.status = Registration.StatusChoices.CONFIRMED + registration_updates.append("status") + if registration_updates: + registration.save(update_fields=list(set(registration_updates))) + else: + registration.save(update_fields=["status"]) + + return { + "start_pay_url": None, + "authority": None, + "base_amount": event.price, + "discount_amount": discount_amount if discount_amount else 0, + "amount": 0, + } + + if registration_updates: + registration.save(update_fields=list(set(registration_updates))) + + pay = Payment.objects.create( + user=request.auth, + event=event, + base_amount=event.price, + discount_code=discount_code, + discount_amount=discount_amount, + amount=final_amount, + status=Payment.OrderStatusChoices.INIT, + registration=registration, + ) + + callback_url = getattr(settings, "ZARINPAL_CALLBACK_URL", "http://localhost:8000/api/payments/callback") + body = { + "merchant_id": settings.ZARINPAL_MERCHANT_ID, + "amount": final_amount, + "callback_url": callback_url, + "description": payload.description, + "metadata": { + k: v for k, v in { + "mobile": payload.mobile, + "email": payload.email, + "event_id": event.id, + "user_id": request.auth.id, + "payment_id": pay.id, + "discount_code": discount_code.code if discount_code else None, + }.items() if v + } + } + + try: + response = requests.post( + settings.ZARINPAL_REQUEST_URL, + json=body, + headers={"accept":"application/json","content-type":"application/json"}, + timeout=15 + ) + jd = response.json() + except Exception as e: + pay.delete() + raise HttpError(502, f"Gateway request failed: {e}") + + code = (jd.get("data") or {}).get("code") + if code != 100: + pay.delete() + raise HttpError(502, f"Zarinpal error: {jd.get('errors') or jd}") + + authority = jd["data"]["authority"] + pay.authority = authority + pay.status = Payment.OrderStatusChoices.PENDING + pay.save(update_fields=["authority","status"]) + + return { + "start_pay_url": f"{settings.ZARINPAL_STARTPAY}{authority}", + "authority": authority, + "base_amount": event.price, + "discount_amount": discount_amount if discount_amount else 0, + "amount": final_amount, + } + +@payments_router.get("callback") +def callback(request, Authority: str | None = None, Status: str | None = None): + if not Authority: + raise HttpError(400, "Missing Authority") + + pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first() + if not pay: + raise HttpError(404, "Payment not found") + + if Status != "OK": + pay.status = Payment.OrderStatusChoices.CANCELED + pay.save(update_fields=["status"]) + return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}") + + verify_body = { + "merchant_id": settings.ZARINPAL_MERCHANT_ID, + "amount": pay.amount, + "authority": Authority, + } + + try: + vresp = requests.post( + settings.ZARINPAL_VERIFY_URL, + json=verify_body, + headers={"accept":"application/json","content-type":"application/json"}, + timeout=15 + ) + vjd = vresp.json() + except Exception: + pay.status = Payment.OrderStatusChoices.FAILED + pay.save(update_fields=["status"]) + return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}") + + vcode = (vjd.get("data") or {}).get("code") + if vcode in (100, 101): + data = vjd.get("data") or {} + pay.status = Payment.OrderStatusChoices.PAID + pay.ref_id = data.get("ref_id") + pay.card_pan = data.get("card_pan") + pay.card_hash = data.get("card_hash") + pay.verified_at = timezone.now() + pay.save(update_fields=["status", "ref_id", "card_pan", "card_hash", "verified_at"]) + + registration = pay.registration or Registration.objects.filter( + user=pay.user, + event=pay.event, + status=Registration.StatusChoices.PENDING, + ).first() + if registration: + registration.status = Registration.StatusChoices.CONFIRMED + updates = ["status"] + if registration.final_price is None: + registration.final_price = pay.amount + updates.append("final_price") + registration.save(update_fields=updates) + + return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=success&event_id={pay.event_id}&ref_id={pay.ref_id}") + + pay.status = Payment.OrderStatusChoices.FAILED + pay.save(update_fields=["status"]) + return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}") + +@payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut) +def payment_by_ref(request, ref_id: str): + pay = get_object_or_404(Payment.objects.select_related("event"), ref_id=ref_id) + ev = pay.event + return { + "ref_id": pay.ref_id, + "authority": pay.authority, + "base_amount": pay.base_amount, + "discount_amount": pay.discount_amount or 0, + "amount": pay.amount, + "status": pay.get_status_display(), + "verified_at": pay.verified_at.isoformat() if pay.verified_at else None, + "event": { + "id": ev.id, + "title": ev.title, + "slug": ev.slug, + "image_url": request.build_absolute_uri(ev.featured_image.url) if ev.featured_image else None, + "success_markdown": ev.registration_success_markdown, + }, + } + +@payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth) +def check_coupon(request, payload: CouponVerifyIn): + event = get_object_or_404(Event, id=payload.event_id) + code = payload.code + + if not code: + raise HttpError(404, "لطفا کد تخفیف را وارد کنید") + + try: + c = DiscountCode.objects.get(code=code, applicable_events=event, is_active=True) + final_price, disc = c.calculate_discount(event, request.auth) + return { + "discount_amount": disc, + "final_price": final_price, + } + + except DiscountCode.DoesNotExist: + raise HttpError(404, "کد تخفیف معتبر نیست") diff --git a/backend/blog/admin.py b/backend/blog/admin.py new file mode 100644 index 0000000..5cae36b --- /dev/null +++ b/backend/blog/admin.py @@ -0,0 +1,159 @@ +from django import forms +from django.contrib import admin + +from import_export.admin import ImportExportModelAdmin +from simplemde.widgets import SimpleMDEEditor + +from blog.models import Category, Tag, Post, Comment, Like +from blog.resources import PostResource, CategoryResource +from utils.admin import SoftDeleteListFilter, BaseModelAdmin + +@admin.register(Category) +class CategoryAdmin(BaseModelAdmin, ImportExportModelAdmin): + resource_class = CategoryResource + list_display = ('name', 'slug', 'created_at', 'is_deleted') + list_filter = ('created_at', 'is_deleted', SoftDeleteListFilter) + search_fields = ('name', 'description') + prepopulated_fields = {'slug': ('name',)} + readonly_fields = ('created_at', 'updated_at', 'deleted_at') + + fieldsets = ( + ('Content', { + 'fields': ('name', 'slug', 'description') + }), + ('Metadata', { + 'fields': ('created_at', 'updated_at') + }), + ('Soft Delete', { + 'fields': ('is_deleted', 'deleted_at'), + 'classes': ('collapse',) + }) + ) + actions = BaseModelAdmin.actions + ['restore_categories'] + + def restore_categories(self, request, queryset): + for category in queryset: + category.restore() + self.message_user(request, f"Restored {queryset.count()} categories.") + restore_categories.short_description = "Restore selected categories" + +@admin.register(Tag) +class TagAdmin(BaseModelAdmin, ImportExportModelAdmin): + list_display = ('name', 'slug', 'created_at', 'is_deleted') + list_filter = ('created_at', 'is_deleted', SoftDeleteListFilter) + search_fields = ('name',) + prepopulated_fields = {'slug': ('name',)} + readonly_fields = ('created_at', 'updated_at', 'deleted_at') + + fieldsets = ( + ('Content', { + 'fields': ('name', 'slug') + }), + ('Metadata', { + 'fields': ('created_at', 'updated_at') + }), + ('Soft Delete', { + 'fields': ('is_deleted', 'deleted_at'), + 'classes': ('collapse',) + }) + ) + + +class PostAdminForm(forms.ModelForm): + content = forms.CharField(widget=SimpleMDEEditor()) + excerpt = forms.CharField(widget=SimpleMDEEditor()) + + class Meta: + model = Post + fields = '__all__' + + +@admin.register(Post) +class PostAdmin(BaseModelAdmin, ImportExportModelAdmin): + form = PostAdminForm + resource_class = PostResource + list_display = ('title', 'author', 'status', 'category', 'is_featured', 'published_at', 'created_at') + list_filter = ('status', 'is_featured', 'category', 'tags', 'created_at', 'published_at', SoftDeleteListFilter) + search_fields = ('title', 'content', 'author__username') + prepopulated_fields = {'slug': ('title',)} + filter_horizontal = ('tags',) + date_hierarchy = 'published_at' + + fieldsets = ( + ('Content', { + 'fields': ('title', 'slug', 'content', 'excerpt', 'featured_image') + }), + ('Metadata', { + 'fields': ('author', 'category', 'tags', 'status', 'is_featured', 'published_at') + }), + ('Soft Delete', { + 'fields': ('is_deleted', 'deleted_at'), + 'classes': ('collapse',) + }), + ) + + readonly_fields = ('deleted_at',) + + actions = BaseModelAdmin.actions + ['make_published', 'make_draft', 'make_featured', 'restore_posts'] + + def make_published(self, request, queryset): + queryset.update(status='published') + self.message_user(request, f"Published {queryset.count()} posts.") + make_published.short_description = "Mark selected posts as published" + + def make_draft(self, request, queryset): + queryset.update(status='draft') + self.message_user(request, f"Marked {queryset.count()} posts as draft.") + make_draft.short_description = "Mark selected posts as draft" + + def make_featured(self, request, queryset): + queryset.update(is_featured=True) + self.message_user(request, f"Featured {queryset.count()} posts.") + make_featured.short_description = "Mark selected posts as featured" + + def restore_posts(self, request, queryset): + for post in queryset: + post.restore() + self.message_user(request, f"Restored {queryset.count()} posts.") + restore_posts.short_description = "Restore selected posts" + +@admin.register(Comment) +class CommentAdmin(BaseModelAdmin): + list_display = ('author', 'post', 'content_preview', 'is_approved', 'created_at') + list_filter = ('is_approved', 'created_at', 'post', SoftDeleteListFilter) + search_fields = ('content', 'author__username', 'author__last_name', 'author__first_name', 'post__title') + readonly_fields = ('content_preview', 'created_at', 'updated_at', 'deleted_at') + + fieldsets = ( + ('Content', { + 'fields': ('post', 'author', 'content') + }), + ('Metadata', { + 'fields': ('is_approved', 'created_at', 'updated_at') + }), + ('Soft Delete', { + 'fields': ('is_deleted', 'deleted_at'), + 'classes': ('collapse',) + }) + ) + actions = BaseModelAdmin.actions + ['approve_comments', 'disapprove_comments'] + + def content_preview(self, obj): + return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content + content_preview.short_description = 'Content Preview' + + def approve_comments(self, request, queryset): + queryset.update(is_approved=True) + self.message_user(request, f"Approved {queryset.count()} comments.") + approve_comments.short_description = "Approve selected comments" + + def disapprove_comments(self, request, queryset): + queryset.update(is_approved=False) + self.message_user(request, f"Disapproved {queryset.count()} comments.") + disapprove_comments.short_description = "Disapprove selected comments" + +@admin.register(Like) +class LikeAdmin(BaseModelAdmin): + list_display = ('user', 'post', 'created_at') + list_filter = ('created_at', 'post') + search_fields = ('user__username', 'post__title') diff --git a/backend/blog/apps.py b/backend/blog/apps.py new file mode 100644 index 0000000..c35af73 --- /dev/null +++ b/backend/blog/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class BlogConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'blog' diff --git a/backend/blog/fixtures/blog.json b/backend/blog/fixtures/blog.json new file mode 100644 index 0000000..9c6ecf5 --- /dev/null +++ b/backend/blog/fixtures/blog.json @@ -0,0 +1,672 @@ +[ + { + "model": "blog.category", + "pk": 1, + "fields": { + "name": "هوش مصنوعی", + "slug": "artificial-intelligence", + "description": "مقالات مربوط به هوش مصنوعی و یادگیری ماشین", + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-01T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.category", + "pk": 2, + "fields": { + "name": "برنامه‌نویسی وب", + "slug": "web-programming", + "description": "آموزش‌ها و مقالات مربوط به توسعه وب", + "created_at": "2024-01-02T10:00:00Z", + "updated_at": "2024-01-02T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.category", + "pk": 3, + "fields": { + "name": "امنیت سایبری", + "slug": "cybersecurity", + "description": "مطالب مربوط به امنیت اطلاعات و سایبری", + "created_at": "2024-01-03T10:00:00Z", + "updated_at": "2024-01-03T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.category", + "pk": 4, + "fields": { + "name": "علم داده", + "slug": "data-science", + "description": "مقالات مربوط به تحلیل داده و علم داده", + "created_at": "2024-01-04T10:00:00Z", + "updated_at": "2024-01-04T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.category", + "pk": 5, + "fields": { + "name": "اپلیکیشن موبایل", + "slug": "mobile-app", + "description": "توسعه اپلیکیشن‌های موبایل", + "created_at": "2024-01-05T10:00:00Z", + "updated_at": "2024-01-05T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.category", + "pk": 6, + "fields": { + "name": "شبکه کامپیوتری", + "slug": "computer-networks", + "description": "مطالب مربوط به شبکه‌های کامپیوتری", + "created_at": "2024-01-06T10:00:00Z", + "updated_at": "2024-01-06T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.category", + "pk": 7, + "fields": { + "name": "بازی‌سازی", + "slug": "game-development", + "description": "آموزش و مقالات مربوط به توسعه بازی", + "created_at": "2024-01-07T10:00:00Z", + "updated_at": "2024-01-07T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.category", + "pk": 8, + "fields": { + "name": "طراحی UI/UX", + "slug": "ui-ux-design", + "description": "طراحی رابط کاربری و تجربه کاربری", + "created_at": "2024-01-08T10:00:00Z", + "updated_at": "2024-01-08T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.category", + "pk": 9, + "fields": { + "name": "اخبار انجمن", + "slug": "association-news", + "description": "اخبار و اطلاعیه‌های انجمن علمی", + "created_at": "2024-01-09T10:00:00Z", + "updated_at": "2024-01-09T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.category", + "pk": 10, + "fields": { + "name": "مسابقات برنامه‌نویسی", + "slug": "programming-contests", + "description": "اطلاعات مربوط به مسابقات برنامه‌نویسی", + "created_at": "2024-01-10T10:00:00Z", + "updated_at": "2024-01-10T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.tag", + "pk": 1, + "fields": { + "name": "پایتون", + "slug": "python", + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-01T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.tag", + "pk": 2, + "fields": { + "name": "جاوااسکریپت", + "slug": "javascript", + "created_at": "2024-01-02T10:00:00Z", + "updated_at": "2024-01-02T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.tag", + "pk": 3, + "fields": { + "name": "ری‌اکت", + "slug": "react", + "created_at": "2024-01-03T10:00:00Z", + "updated_at": "2024-01-03T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.tag", + "pk": 4, + "fields": { + "name": "جنگو", + "slug": "django", + "created_at": "2024-01-04T10:00:00Z", + "updated_at": "2024-01-04T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.tag", + "pk": 5, + "fields": { + "name": "یادگیری عمیق", + "slug": "deep-learning", + "created_at": "2024-01-05T10:00:00Z", + "updated_at": "2024-01-05T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.tag", + "pk": 6, + "fields": { + "name": "تنسورفلو", + "slug": "tensorflow", + "created_at": "2024-01-06T10:00:00Z", + "updated_at": "2024-01-06T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.tag", + "pk": 7, + "fields": { + "name": "کیبرنتیز", + "slug": "kubernetes", + "created_at": "2024-01-07T10:00:00Z", + "updated_at": "2024-01-07T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.tag", + "pk": 8, + "fields": { + "name": "داکر", + "slug": "docker", + "created_at": "2024-01-08T10:00:00Z", + "updated_at": "2024-01-08T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.tag", + "pk": 9, + "fields": { + "name": "گیت", + "slug": "git", + "created_at": "2024-01-09T10:00:00Z", + "updated_at": "2024-01-09T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.tag", + "pk": 10, + "fields": { + "name": "لینوکس", + "slug": "linux", + "created_at": "2024-01-10T10:00:00Z", + "updated_at": "2024-01-10T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.tag", + "pk": 11, + "fields": { + "name": "الگوریتم", + "slug": "algorithm", + "created_at": "2024-01-11T10:00:00Z", + "updated_at": "2024-01-11T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.tag", + "pk": 12, + "fields": { + "name": "ساختمان داده", + "slug": "data-structure", + "created_at": "2024-01-12T10:00:00Z", + "updated_at": "2024-01-12T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.post", + "pk": 1, + "fields": { + "title": "مقدمه‌ای بر یادگیری ماشین با پایتون", + "slug": "introduction-to-machine-learning-with-python", + "content": "# مقدمه‌ای بر یادگیری ماشین با پایتون\n\nیادگیری ماشین یکی از مهم‌ترین شاخه‌های هوش مصنوعی است که امروزه کاربردهای فراوانی در صنایع مختلف دارد.\n\n## کتابخانه‌های مهم\n\n- **Scikit-learn**: برای الگوریتم‌های کلاسیک\n- **TensorFlow**: برای یادگیری عمیق\n- **Pandas**: برای پردازش داده\n- **NumPy**: برای محاسبات عددی\n\n## مثال ساده\n\n```python\nfrom sklearn.linear_model import LinearRegression\nimport numpy as np\n\n# داده‌های نمونه\nX = np.array([[1], [2], [3], [4]])\ny = np.array([2, 4, 6, 8])\n\n# ایجاد مدل\nmodel = LinearRegression()\nmodel.fit(X, y)\n\n# پیش‌بینی\nprint(model.predict([[5]]))\n```\n\nاین مثال ساده نشان می‌دهد که چگونه می‌توان با استفاده از کتابخانه Scikit-learn یک مدل رگرسیون خطی ایجاد کرد.", + "excerpt": "آموزش مقدماتی یادگیری ماشین با استفاده از زبان پایتون و کتابخانه‌های محبوب", + "author": 1, + "status": "published", + "published_at": "2024-01-15T10:00:00Z", + "category": 1, + "is_featured": true, + "created_at": "2024-01-15T09:00:00Z", + "updated_at": "2024-01-15T09:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.post", + "pk": 2, + "fields": { + "title": "ساخت API با Django REST Framework", + "slug": "building-api-with-django-rest-framework", + "content": "# ساخت API با Django REST Framework\n\nDjango REST Framework یکی از قدرتمندترین ابزارها برای ساخت API در پایتون است.\n\n## نصب و راه‌اندازی\n\n```bash\npip install djangorestframework\n```\n\n## ایجاد Serializer\n\n```python\nfrom rest_framework import serializers\nfrom .models import Post\n\nclass PostSerializer(serializers.ModelSerializer):\n class Meta:\n model = Post\n fields = '__all__'\n```\n\n## ایجاد ViewSet\n\n```python\nfrom rest_framework import viewsets\nfrom .models import Post\nfrom .serializers import PostSerializer\n\nclass PostViewSet(viewsets.ModelViewSet):\n queryset = Post.objects.all()\n serializer_class = PostSerializer\n```\n\nبا این روش می‌توانید به راحتی API های قدرتمند و قابل اعتماد بسازید.", + "excerpt": "آموزش گام به گام ساخت API با استفاده از Django REST Framework", + "author": 2, + "status": "published", + "published_at": "2024-01-20T14:30:00Z", + "category": 2, + "is_featured": false, + "created_at": "2024-01-20T13:30:00Z", + "updated_at": "2024-01-20T13:30:00Z", + "is_deleted": false + } + }, + { + "model": "blog.post", + "pk": 3, + "fields": { + "title": "امنیت در اپلیکیشن‌های وب", + "slug": "web-application-security", + "content": "# امنیت در اپلیکیشن‌های وب\n\nامنیت یکی از مهم‌ترین جنبه‌های توسعه اپلیکیشن‌های وب است.\n\n## تهدیدات رایج\n\n- **SQL Injection**: تزریق کد SQL مخرب\n- **XSS**: اجرای اسکریپت مخرب در مرورگر\n- **CSRF**: درخواست جعلی بین سایتی\n- **Authentication Bypass**: دور زدن احراز هویت\n\n## راه‌های محافظت\n\n```python\n# استفاده از ORM برای جلوگیری از SQL Injection\nUser.objects.filter(username=username)\n\n# Escape کردن خروجی HTML\nfrom django.utils.html import escape\nsafe_content = escape(user_input)\n\n# استفاده از CSRF Token\n{% csrf_token %}\n```\n\nهمیشه امنیت را در اولویت قرار دهید.", + "excerpt": "بررسی تهدیدات امنیتی رایج در اپلیکیشن‌های وب و راه‌های مقابله با آن‌ها", + "author": 3, + "status": "published", + "published_at": "2024-01-25T16:00:00Z", + "category": 3, + "is_featured": true, + "created_at": "2024-01-25T15:00:00Z", + "updated_at": "2024-01-25T15:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.post", + "pk": 4, + "fields": { + "title": "تحلیل داده با Pandas", + "slug": "data-analysis-with-pandas", + "content": "# تحلیل داده با Pandas\n\nPandas یکی از قدرتمندترین کتابخانه‌های پایتون برای تحلیل داده است.\n\n## خواندن داده\n\n```python\nimport pandas as pd\n\n# خواندن از CSV\ndf = pd.read_csv('data.csv')\n\n# خواندن از Excel\ndf = pd.read_excel('data.xlsx')\n\n# خواندن از JSON\ndf = pd.read_json('data.json')\n```\n\n## عملیات پایه\n\n```python\n# نمایش اطلاعات کلی\nprint(df.info())\nprint(df.describe())\n\n# فیلتر کردن\nfiltered_df = df[df['age'] > 25]\n\n# گروه‌بندی\ngrouped = df.groupby('category').mean()\n```\n\nPandas ابزاری قدرتمند برای تحلیل داده است.", + "excerpt": "آموزش کار با کتابخانه Pandas برای تحلیل و پردازش داده در پایتون", + "author": 4, + "status": "published", + "published_at": "2024-02-01T11:00:00Z", + "category": 4, + "is_featured": false, + "created_at": "2024-02-01T10:00:00Z", + "updated_at": "2024-02-01T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.post", + "pk": 5, + "fields": { + "title": "توسعه اپلیکیشن موبایل با React Native", + "slug": "mobile-app-development-with-react-native", + "content": "# توسعه اپلیکیشن موبایل با React Native\n\nReact Native امکان توسعه اپلیکیشن‌های موبایل کراس پلتفرم را فراهم می‌کند.\n\n## مزایا\n\n- **کراس پلتفرم**: یک کد برای iOS و Android\n- **Performance**: عملکرد نزدیک به Native\n- **Hot Reload**: تغییرات فوری\n- **Community**: جامعه بزرگ و فعال\n\n## شروع پروژه\n\n```bash\nnpx react-native init MyApp\ncd MyApp\nnpx react-native run-android\n```\n\n## کامپوننت ساده\n\n```jsx\nimport React from 'react';\nimport { View, Text, StyleSheet } from 'react-native';\n\nconst App = () => {\n return (\n \n سلام دنیا!\n \n );\n};\n\nconst styles = StyleSheet.create({\n container: {\n flex: 1,\n justifyContent: 'center',\n alignItems: 'center',\n },\n title: {\n fontSize: 24,\n fontWeight: 'bold',\n },\n});\n\nexport default App;\n```", + "excerpt": "راهنمای شروع توسعه اپلیکیشن موبایل با React Native", + "author": 5, + "status": "published", + "published_at": "2024-02-05T13:30:00Z", + "category": 5, + "is_featured": false, + "created_at": "2024-02-05T12:30:00Z", + "updated_at": "2024-02-05T12:30:00Z", + "is_deleted": false + } + }, + { + "model": "blog.post", + "pk": 6, + "fields": { + "title": "مبانی شبکه‌های کامپیوتری", + "slug": "computer-networks-fundamentals", + "content": "# مبانی شبکه‌های کامپیوتری\n\nشبکه‌های کامپیوتری پایه و اساس ارتباطات مدرن هستند.\n\n## مدل OSI\n\n1. **Physical Layer**: لایه فیزیکی\n2. **Data Link Layer**: لایه پیوند داده\n3. **Network Layer**: لایه شبکه\n4. **Transport Layer**: لایه انتقال\n5. **Session Layer**: لایه جلسه\n6. **Presentation Layer**: لایه ارائه\n7. **Application Layer**: لایه کاربرد\n\n## پروتکل‌های مهم\n\n- **TCP/IP**: پروتکل اصلی اینترنت\n- **HTTP/HTTPS**: انتقال صفحات وب\n- **FTP**: انتقال فایل\n- **SMTP**: ارسال ایمیل\n- **DNS**: تبدیل نام دامنه\n\n## مثال ساده با Python\n\n```python\nimport socket\n\n# ایجاد سوکت\ns = socket.socket(socket.AF_INET, socket.SOCK_STREAM)\n\n# اتصال به سرور\ns.connect(('google.com', 80))\n\n# ارسال درخواست HTTP\nrequest = \"GET / HTTP/1.1\\r\\nHost: google.com\\r\\n\\r\\n\"\ns.send(request.encode())\n\n# دریافت پاسخ\nresponse = s.recv(1024)\nprint(response.decode())\n\ns.close()\n```", + "excerpt": "آشنایی با مفاهیم پایه شبکه‌های کامپیوتری و پروتکل‌های مهم", + "author": 6, + "status": "published", + "published_at": "2024-02-10T15:00:00Z", + "category": 6, + "is_featured": false, + "created_at": "2024-02-10T14:00:00Z", + "updated_at": "2024-02-10T14:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.post", + "pk": 7, + "fields": { + "title": "ساخت بازی با Unity", + "slug": "game-development-with-unity", + "content": "# ساخت بازی با Unity\n\nUnity یکی از محبوب‌ترین موتورهای بازی‌سازی است.\n\n## ویژگی‌های Unity\n\n- **کراس پلتفرم**: انتشار در پلتفرم‌های مختلف\n- **Visual Scripting**: برنامه‌نویسی بصری\n- **Asset Store**: فروشگاه منابع\n- **Community**: جامعه بزرگ\n\n## اسکریپت ساده C#\n\n```csharp\nusing UnityEngine;\n\npublic class PlayerController : MonoBehaviour\n{\n public float speed = 5.0f;\n \n void Update()\n {\n float horizontal = Input.GetAxis(\"Horizontal\");\n float vertical = Input.GetAxis(\"Vertical\");\n \n Vector3 movement = new Vector3(horizontal, 0, vertical);\n transform.Translate(movement * speed * Time.deltaTime);\n }\n}\n```\n\n## مراحل ساخت بازی\n\n1. **طراحی**: ایده و مفهوم بازی\n2. **Prototyping**: نمونه اولیه\n3. **Development**: توسعه اصلی\n4. **Testing**: تست و رفع باگ\n5. **Publishing**: انتشار بازی\n\nUnity ابزاری قدرتمند برای ساخت بازی است.", + "excerpt": "راهنمای شروع بازی‌سازی با موتور Unity", + "author": 7, + "status": "published", + "published_at": "2024-02-15T12:00:00Z", + "category": 7, + "is_featured": true, + "created_at": "2024-02-15T11:00:00Z", + "updated_at": "2024-02-15T11:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.post", + "pk": 8, + "fields": { + "title": "اصول طراحی UI/UX", + "slug": "ui-ux-design-principles", + "content": "# اصول طراحی UI/UX\n\nطراحی رابط کاربری و تجربه کاربری نقش مهمی در موفقیت محصولات دیجیتال دارد.\n\n## اصول UI\n\n- **Consistency**: یکنواختی در طراحی\n- **Hierarchy**: سلسله مراتب بصری\n- **Contrast**: تضاد مناسب\n- **Alignment**: تراز بندی صحیح\n- **Proximity**: قرارگیری عناصر مرتبط\n\n## اصول UX\n\n- **Usability**: قابلیت استفاده\n- **Accessibility**: دسترسی‌پذیری\n- **User-Centered**: محوریت کاربر\n- **Feedback**: بازخورد مناسب\n- **Error Prevention**: جلوگیری از خطا\n\n## ابزارهای طراحی\n\n- **Figma**: طراحی رابط کاربری\n- **Adobe XD**: پروتوتایپ سازی\n- **Sketch**: طراحی برای Mac\n- **InVision**: همکاری تیمی\n\n## فرآیند طراحی\n\n1. **Research**: تحقیق و بررسی\n2. **Wireframing**: طراحی اسکلت\n3. **Prototyping**: نمونه‌سازی\n4. **Testing**: تست با کاربران\n5. **Iteration**: بهبود مداوم", + "excerpt": "آشنایی با اصول و مبانی طراحی رابط کاربری و تجربه کاربری", + "author": 8, + "status": "published", + "published_at": "2024-02-20T14:30:00Z", + "category": 8, + "is_featured": false, + "created_at": "2024-02-20T13:30:00Z", + "updated_at": "2024-02-20T13:30:00Z", + "is_deleted": false + } + }, + { + "model": "blog.post", + "pk": 9, + "fields": { + "title": "اطلاعیه برگزاری مسابقه برنامه‌نویسی", + "slug": "programming-contest-announcement", + "content": "# اطلاعیه برگزاری مسابقه برنامه‌نویسی\n\nانجمن علمی مهندسی کامپیوتر دانشگاه برگزاری مسابقه برنامه‌نویسی بهاری را اعلام می‌کند.\n\n## جزئیات مسابقه\n\n- **تاریخ**: ۲۲ مارس ۲۰۲۴\n- **زمان**: ۹ صبح تا ۱۲ ظهر\n- **مکان**: آزمایشگاه کامپیوتر شماره ۱\n- **مدت زمان**: ۳ ساعت\n- **تعداد مسائل**: ۸ مسئله\n\n## جوایز\n\n- **نفر اول**: ۵ میلیون تومان\n- **نفر دوم**: ۳ میلیون تومان\n- **نفر سوم**: ۲ میلیون تومان\n\n## قوانین\n\n- مسابقه به صورت انفرادی برگزار می‌شود\n- زبان‌های مجاز: C++, Java, Python\n- استفاده از اینترنت ممنوع است\n- ثبت نام تا ۲۰ مارس ادامه دارد\n\n## ثبت نام\n\nبرای ثبت نام به دفتر انجمن مراجعه کنید یا از طریق وب‌سایت اقدام نمایید.\n\nمنتظر حضور گرم شما هستیم!", + "excerpt": "اطلاعیه برگزاری مسابقه برنامه‌نویسی بهاری انجمن علمی", + "author": 1, + "status": "published", + "published_at": "2024-02-25T10:00:00Z", + "category": 9, + "is_featured": true, + "created_at": "2024-02-25T09:00:00Z", + "updated_at": "2024-02-25T09:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.post", + "pk": 10, + "fields": { + "title": "نتایج مسابقه ACM ICPC منطقه‌ای", + "slug": "acm-icpc-regional-results", + "content": "# نتایج مسابقه ACM ICPC منطقه‌ای\n\nتیم‌های دانشگاه ما در مسابقه ACM ICPC منطقه‌ای عملکرد درخشانی داشتند.\n\n## نتایج تیم‌ها\n\n### تیم Alpha\n- **اعضا**: علی احمدی، سارا محمدی، رضا کریمی\n- **رتبه**: ۵ منطقه‌ای\n- **مسائل حل شده**: ۷ از ۱۲\n\n### تیم Beta\n- **اعضا**: مریم حسینی، حسن زارع، زهرا صفری\n- **رتبه**: ۱۲ منطقه‌ای\n- **مسائل حل شده**: ۵ از ۱۲\n\n### تیم Gamma\n- **اعضا**: محمد رحمانی، فاطمه مرادی، امیر قربانی\n- **رتبه**: ۱۸ منطقه‌ای\n- **مسائل حل شده**: ۴ از ۱۲\n\n## تبریک و تشکر\n\nاز تمامی شرکت‌کنندگان تشکر می‌کنیم و امیدواریم سال آینده نتایج بهتری کسب کنیم.\n\n## آماده‌سازی برای سال آینده\n\nبرای آماده‌سازی تیم‌های سال آینده، کارگاه‌های تمرینی برگزار خواهد شد.", + "excerpt": "گزارش عملکرد تیم‌های دانشگاه در مسابقه ACM ICPC منطقه‌ای", + "author": 2, + "status": "published", + "published_at": "2024-03-01T16:00:00Z", + "category": 10, + "is_featured": false, + "created_at": "2024-03-01T15:00:00Z", + "updated_at": "2024-03-01T15:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.comment", + "pk": 1, + "fields": { + "post": 1, + "author": 3, + "content": "مقاله بسیار مفیدی بود. ممنون از نویسنده", + "is_approved": true, + "created_at": "2024-01-16T10:00:00Z", + "updated_at": "2024-01-16T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.comment", + "pk": 2, + "fields": { + "post": 1, + "author": 4, + "content": "آیا می‌توانید مثال‌های بیشتری ارائه دهید؟", + "is_approved": true, + "created_at": "2024-01-17T11:00:00Z", + "updated_at": "2024-01-17T11:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.comment", + "pk": 3, + "fields": { + "post": 2, + "author": 5, + "content": "Django REST Framework واقعاً قدرتمند است", + "is_approved": true, + "created_at": "2024-01-21T09:00:00Z", + "updated_at": "2024-01-21T09:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.comment", + "pk": 4, + "fields": { + "post": 3, + "author": 6, + "content": "امنیت واقعاً مهم است. مقاله خوبی بود", + "is_approved": true, + "created_at": "2024-01-26T12:00:00Z", + "updated_at": "2024-01-26T12:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.comment", + "pk": 5, + "fields": { + "post": 4, + "author": 7, + "content": "Pandas برای تحلیل داده عالی است", + "is_approved": true, + "created_at": "2024-02-02T14:00:00Z", + "updated_at": "2024-02-02T14:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.comment", + "pk": 6, + "fields": { + "post": 5, + "author": 8, + "content": "React Native گزینه خوبی برای موبایل است", + "is_approved": true, + "created_at": "2024-02-06T15:00:00Z", + "updated_at": "2024-02-06T15:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.comment", + "pk": 7, + "fields": { + "post": 6, + "author": 9, + "content": "شبکه پایه همه چیز است", + "is_approved": true, + "created_at": "2024-02-11T16:00:00Z", + "updated_at": "2024-02-11T16:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.comment", + "pk": 8, + "fields": { + "post": 7, + "author": 10, + "content": "Unity برای شروع بازی‌سازی عالی است", + "is_approved": true, + "created_at": "2024-02-16T13:00:00Z", + "updated_at": "2024-02-16T13:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.comment", + "pk": 9, + "fields": { + "post": 8, + "author": 11, + "content": "طراحی UI/UX خیلی مهم است", + "is_approved": true, + "created_at": "2024-02-21T17:00:00Z", + "updated_at": "2024-02-21T17:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.comment", + "pk": 10, + "fields": { + "post": 9, + "author": 12, + "content": "حتماً در مسابقه شرکت می‌کنم", + "is_approved": true, + "created_at": "2024-02-26T11:00:00Z", + "updated_at": "2024-02-26T11:00:00Z", + "is_deleted": false + } + }, + { + "model": "blog.like", + "pk": 1, + "fields": { + "post": 1, + "user": 3, + "created_at": "2024-01-16T10:30:00Z" + } + }, + { + "model": "blog.like", + "pk": 2, + "fields": { + "post": 1, + "user": 4, + "created_at": "2024-01-17T11:30:00Z" + } + }, + { + "model": "blog.like", + "pk": 3, + "fields": { + "post": 1, + "user": 5, + "created_at": "2024-01-18T12:00:00Z" + } + }, + { + "model": "blog.like", + "pk": 4, + "fields": { + "post": 2, + "user": 6, + "created_at": "2024-01-21T09:30:00Z" + } + }, + { + "model": "blog.like", + "pk": 5, + "fields": { + "post": 2, + "user": 7, + "created_at": "2024-01-22T10:00:00Z" + } + }, + { + "model": "blog.like", + "pk": 6, + "fields": { + "post": 3, + "user": 8, + "created_at": "2024-01-26T12:30:00Z" + } + }, + { + "model": "blog.like", + "pk": 7, + "fields": { + "post": 3, + "user": 9, + "created_at": "2024-01-27T13:00:00Z" + } + }, + { + "model": "blog.like", + "pk": 8, + "fields": { + "post": 4, + "user": 10, + "created_at": "2024-02-02T14:30:00Z" + } + }, + { + "model": "blog.like", + "pk": 9, + "fields": { + "post": 5, + "user": 11, + "created_at": "2024-02-06T15:30:00Z" + } + }, + { + "model": "blog.like", + "pk": 10, + "fields": { + "post": 6, + "user": 12, + "created_at": "2024-02-11T16:30:00Z" + } + }, + { + "model": "blog.like", + "pk": 11, + "fields": { + "post": 7, + "user": 1, + "created_at": "2024-02-16T13:30:00Z" + } + }, + { + "model": "blog.like", + "pk": 12, + "fields": { + "post": 8, + "user": 2, + "created_at": "2024-02-21T17:30:00Z" + } + } +] diff --git a/backend/blog/migrations/0001_initial.py b/backend/blog/migrations/0001_initial.py new file mode 100644 index 0000000..2f24b9f --- /dev/null +++ b/backend/blog/migrations/0001_initial.py @@ -0,0 +1,89 @@ +# Generated by Django 5.2.5 on 2025-10-16 12:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Category', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('name', models.CharField(max_length=100, unique=True)), + ('slug', models.SlugField(blank=True, max_length=100, unique=True)), + ('description', models.TextField(blank=True)), + ], + options={ + 'verbose_name_plural': 'Categories', + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Comment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('content', models.TextField()), + ('is_approved', models.BooleanField(default=True)), + ], + options={ + 'ordering': ['created_at'], + }, + ), + migrations.CreateModel( + name='Like', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + ), + migrations.CreateModel( + name='Post', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('title', models.CharField(max_length=200)), + ('slug', models.SlugField(blank=True, max_length=200, unique=True)), + ('content', models.TextField(help_text='Content in Markdown format')), + ('excerpt', models.TextField(blank=True, max_length=300)), + ('featured_image', models.ImageField(blank=True, null=True, upload_to='blog/featured/')), + ('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published')], default='draft', max_length=10)), + ('published_at', models.DateTimeField(blank=True, null=True)), + ('is_featured', models.BooleanField(default=False)), + ], + options={ + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='Tag', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(blank=True, unique=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + ] diff --git a/backend/blog/migrations/0002_initial.py b/backend/blog/migrations/0002_initial.py new file mode 100644 index 0000000..5a1655b --- /dev/null +++ b/backend/blog/migrations/0002_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 5.2.5 on 2025-10-16 12:07 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('blog', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='comment', + name='author', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='comment', + name='parent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='replies', to='blog.comment'), + ), + migrations.AddField( + model_name='like', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='post', + name='author', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='posts', to=settings.AUTH_USER_MODEL), + ), + migrations.AddField( + model_name='post', + name='category', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='posts', to='blog.category'), + ), + migrations.AddField( + model_name='like', + name='post', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='likes', to='blog.post'), + ), + migrations.AddField( + model_name='comment', + name='post', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='comments', to='blog.post'), + ), + migrations.AddField( + model_name='post', + name='tags', + field=models.ManyToManyField(blank=True, related_name='posts', to='blog.tag'), + ), + migrations.AddIndex( + model_name='like', + index=models.Index(fields=['post'], name='blog_like_post_id_c95f0b_idx'), + ), + migrations.AlterUniqueTogether( + name='like', + unique_together={('post', 'user')}, + ), + migrations.AddIndex( + model_name='comment', + index=models.Index(fields=['post', 'is_approved'], name='blog_commen_post_id_7710b1_idx'), + ), + migrations.AddIndex( + model_name='post', + index=models.Index(fields=['status', 'published_at'], name='blog_post_status_5b2843_idx'), + ), + migrations.AddIndex( + model_name='post', + index=models.Index(fields=['is_featured'], name='blog_post_is_feat_837e2e_idx'), + ), + ] diff --git a/backend/blog/migrations/__init__.py b/backend/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/blog/models.py b/backend/blog/models.py new file mode 100644 index 0000000..a76db2e --- /dev/null +++ b/backend/blog/models.py @@ -0,0 +1,137 @@ +from django.db import models +from django.conf import settings +from django.utils.text import slugify +from django.utils import timezone + +import markdown + +from utils.models import BaseModel + +class Category(BaseModel): + name = models.CharField(max_length=100, unique=True) + slug = models.SlugField(max_length=100, unique=True, blank=True) + description = models.TextField(blank=True) + + class Meta: + verbose_name_plural = "Categories" + ordering = ['name'] + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name) + super().save(*args, **kwargs) + +class Tag(BaseModel): + name = models.CharField(max_length=50, unique=True) + slug = models.SlugField(max_length=50, unique=True, blank=True) + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.name) + super().save(*args, **kwargs) + +class Post(BaseModel): + class StatusChoices(models.TextChoices): + DRAFT = 'draft', 'Draft' + PUBLISHED = 'published', 'Published' + + title = models.CharField(max_length=200) + slug = models.SlugField(max_length=200, unique=True, blank=True) + content = models.TextField(help_text="Content in Markdown format") + excerpt = models.TextField(max_length=300, blank=True) + author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='posts') + featured_image = models.ImageField(upload_to='blog/featured/', null=True, blank=True) + status = models.CharField(max_length=10, choices=StatusChoices.choices, default=StatusChoices.DRAFT) + published_at = models.DateTimeField(null=True, blank=True) + category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='posts') + tags = models.ManyToManyField(Tag, blank=True, related_name='posts') + is_featured = models.BooleanField(default=False) + + class Meta: + ordering = ['-created_at'] + indexes = [ + models.Index(fields=['status', 'published_at']), + models.Index(fields=['is_featured']), + ] + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.title) + + # Auto-generate excerpt if not provided + if not self.excerpt and self.content: + # Convert markdown to plain text for excerpt + plain_text = markdown.markdown(self.content, extensions=['markdown.extensions.extra']) + # Remove HTML tags and truncate + import re + plain_text = re.sub('<[^<]+?>', '', plain_text) + self.excerpt = plain_text[:297] + '...' if len(plain_text) > 300 else plain_text + + if self.status == Post.StatusChoices.PUBLISHED and not self.published_at: + self.published_at = timezone.now() + + super().save(*args, **kwargs) + + @property + def content_html(self): + """Convert markdown content to HTML""" + return markdown.markdown( + self.content, + extensions=[ + 'markdown.extensions.extra', + 'markdown.extensions.codehilite', + 'markdown.extensions.toc', + ] + ) + + @property + def reading_time(self): + """Estimate reading time in minutes assuming 200 words per minute.""" + word_count = len(self.content.split()) + return max(1, word_count // 200) + +class Comment(BaseModel): + post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments') + author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='comments') + content = models.TextField() + parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='replies') + is_approved = models.BooleanField(default=True) + + class Meta: + ordering = ['created_at'] + indexes = [ + models.Index(fields=['post', 'is_approved']), + ] + + def __str__(self): + return f'Comment by {self.author.username} on {self.post.title}' + + @property + def is_reply(self): + return self.parent is not None + +class Like(models.Model): + post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='likes') + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='likes') + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + unique_together = ['post', 'user'] + indexes = [ + models.Index(fields=['post']), + ] + + def __str__(self): + return f'{self.user.username} likes {self.post.title}' diff --git a/backend/blog/resources.py b/backend/blog/resources.py new file mode 100644 index 0000000..4b8337d --- /dev/null +++ b/backend/blog/resources.py @@ -0,0 +1,32 @@ +from import_export import resources, fields +from import_export.widgets import ForeignKeyWidget, ManyToManyWidget + +from users.models import User +from blog.models import Post, Category, Tag + +class CategoryResource(resources.ModelResource): + class Meta: + model = Category + fields = ('id', 'name', 'slug', 'description', 'created_at') + +class PostResource(resources.ModelResource): + author = fields.Field( + column_name='author', + attribute='author', + widget=ForeignKeyWidget(User, 'username') + ) + category = fields.Field( + column_name='category', + attribute='category', + widget=ForeignKeyWidget(Category, 'name') + ) + tags = fields.Field( + column_name='tags', + attribute='tags', + widget=ManyToManyWidget(Tag, field='name', separator='|') + ) + + class Meta: + model = Post + fields = ('id', 'title', 'slug', 'content', 'excerpt', 'author', + 'category', 'tags', 'status', 'is_featured', 'published_at', 'created_at') diff --git a/backend/celerybeat-schedule b/backend/celerybeat-schedule new file mode 100644 index 0000000000000000000000000000000000000000..550c20728a2680835aeeac2a62aca5e81db86e5e GIT binary patch literal 18455 zcmeHO-A)rh6dq`yKcJ<8KrEt(7Zk`A4c0_s0)}v58of}Ai5G6R+tJ;$ovpjGAQ)f7 z2l3AM0ABbgzJllMObgwXqydT9=D^OGnKOT9zB%8SZ3(}>y`5)sj714D%yT;b^YaIm zc2O{E(cCdArwVT9u5)Dj!AS ziZFZc88)WZ2nA{%M1b%i|LIa_&^w4<#E&uI$C_y#@>zWY&48;fK%&zflhdW!o!)MS zTwla*;y1nBC>9K%$CclIrT4J*YA9`U=giGZpWEoogCa9U=Btd%mU6IpE~LNoNX5Pk zWQ7!QFB#lBSe7S@ZR&dize2nLDH*maE0x#vOY}#V2hbt?6+{I;mHL6}3DZO!r0D;q z)J~_Wm1<=hyU7#Xiav}#P))r#*uyrwUb(R9E#A+Tgy?h2_c6P41bR7yix?eqXThbK>9z26GxdYsc+H{Ki=AGi4vP8zovbm@()2UTo~OdrAcg~;LgAsA}-yzPnx zuns2xi=RTiKGfpKiK9Vg-=MgeFagdLH#BmY6qt&QBY+%^2XAww%)(mXnF0l5_W$x0 z*_=e)7Oz)t=X)UbIoHHhFc+2@f#T5X((Eo4R#T~8-(jl0T@_$gp!=8HhUv~@J~Um7 z^u+>W(`B@@&-&@;Oh9`H!l&Wo6n`)VC+3IJNVyacI2Y4W3(>dQzVH0u#W~ z=X0q<$AR2RzQ8h*FXFq}NJJa}>lw@J4>^_Mg(87sK1o7=5Fi8y0YV`02;~0)pH-~u literal 0 HcmV?d00001 diff --git a/backend/certificates/__init__.py b/backend/certificates/__init__.py new file mode 100644 index 0000000..f3b0d32 --- /dev/null +++ b/backend/certificates/__init__.py @@ -0,0 +1 @@ +"""""" diff --git a/backend/certificates/admin.py b/backend/certificates/admin.py new file mode 100644 index 0000000..a5c57ce --- /dev/null +++ b/backend/certificates/admin.py @@ -0,0 +1,24 @@ +from django.contrib import admin + +from .models import CertificateTemplate, Skill, UserCertificate + + +@admin.register(Skill) +class SkillAdmin(admin.ModelAdmin): + list_display = ('name', 'created_at') + search_fields = ('name',) + + +@admin.register(CertificateTemplate) +class CertificateTemplateAdmin(admin.ModelAdmin): + list_display = ('event', 'created_at') + search_fields = ('event__title',) + filter_horizontal = ('skills',) + + +@admin.register(UserCertificate) +class UserCertificateAdmin(admin.ModelAdmin): + list_display = ('user', 'event', 'title', 'score', 'issued_at') + list_filter = ('score', 'issued_at') + search_fields = ('user__username', 'title', 'event__title') + filter_horizontal = ('skills',) diff --git a/backend/certificates/apps.py b/backend/certificates/apps.py new file mode 100644 index 0000000..92cd1c5 --- /dev/null +++ b/backend/certificates/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CertificatesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'certificates' diff --git a/backend/certificates/migrations/0001_initial.py b/backend/certificates/migrations/0001_initial.py new file mode 100644 index 0000000..db11a47 --- /dev/null +++ b/backend/certificates/migrations/0001_initial.py @@ -0,0 +1,80 @@ +# Generated by Django 4.2.13 on 2025-11-18 09:47 + +import certificates.models +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('events', '0012_alter_eventemaillog_kind'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Skill', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('name', models.CharField(max_length=120, unique=True)), + ('description', models.TextField(blank=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='CertificateTemplate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('image', models.ImageField(upload_to='certificates/templates/')), + ('event', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='certificate_template', to='events.event')), + ('skills', models.ManyToManyField(blank=True, help_text='Skills covered by this event.', related_name='certificate_templates', to='certificates.skill')), + ], + options={ + 'verbose_name': 'Certificate template', + 'verbose_name_plural': 'Certificate templates', + }, + ), + migrations.CreateModel( + name='UserCertificate', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('certificate_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('code', models.CharField(default=certificates.models._generate_certificate_code, editable=False, max_length=10, unique=True)), + ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True)), + ('score', models.PositiveSmallIntegerField(default=0)), + ('issued_at', models.DateTimeField(default=django.utils.timezone.now)), + ('expires_at', models.DateTimeField(blank=True, null=True)), + ('image', models.ImageField(blank=True, null=True, upload_to='certificates/generated/')), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_certificates', to='events.event')), + ('skills', models.ManyToManyField(blank=True, help_text='Skills demonstrated on this certificate.', related_name='user_certificates', to='certificates.skill')), + ('template', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='awarded_certificates', to='certificates.certificatetemplate')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-issued_at'], + 'indexes': [models.Index(fields=['user', 'event'], name='certificate_user_id_61901c_idx'), models.Index(fields=['event', 'score'], name='certificate_event_i_25b8ab_idx')], + 'unique_together': {('user', 'event')}, + }, + ), + ] diff --git a/backend/certificates/migrations/__init__.py b/backend/certificates/migrations/__init__.py new file mode 100644 index 0000000..f3b0d32 --- /dev/null +++ b/backend/certificates/migrations/__init__.py @@ -0,0 +1 @@ +"""""" diff --git a/backend/certificates/models.py b/backend/certificates/models.py new file mode 100644 index 0000000..d676742 --- /dev/null +++ b/backend/certificates/models.py @@ -0,0 +1,316 @@ +from io import BytesIO +from typing import Optional, Sequence +from uuid import uuid4 + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.core.files.base import ContentFile +from django.db import models +from django.utils import timezone +from PIL import Image, ImageDraw, ImageFont + +from events.models import Registration +from users.models import User +from utils.models import BaseModel + +SHORT_CERTIFICATE_CODE_LENGTH = 10 + + +def _generate_certificate_code() -> str: + return uuid4().hex[:SHORT_CERTIFICATE_CODE_LENGTH] + + +class Skill(BaseModel): + name = models.CharField(max_length=120, unique=True) + description = models.TextField(blank=True) + + class Meta: + ordering = ['name'] + + def __str__(self): + return self.name + + +class CertificateTemplate(BaseModel): + event = models.OneToOneField( + 'events.Event', + on_delete=models.CASCADE, + related_name='certificate_template', + ) + image = models.ImageField(upload_to='certificates/templates/') + skills = models.ManyToManyField( + Skill, + blank=True, + related_name='certificate_templates', + help_text='Skills covered by this event.', + ) + + class Meta: + verbose_name = 'Certificate template' + verbose_name_plural = 'Certificate templates' + + def __str__(self): + return f'{self.event.title} template' + + def _validate_score(self, score: Optional[int]) -> int: + """Normalize score values and ensure they stay within 0-100.""" + if score is None: + raise ValidationError("Score is required") + try: + normalized = int(score) + except (TypeError, ValueError): + raise ValidationError("Score must be an integer between 0 and 100") + if normalized < 0 or normalized > 100: + raise ValidationError("Score must be between 0 and 100") + return normalized + + def _resolve_skill_ids(self, skill_ids: Optional[Sequence[int]]) -> list[int]: + """Return a cleaned list of skill IDs, defaulting to the template skills.""" + if skill_ids is None: + return list(self.skills.values_list('id', flat=True)) + + normalized = [] + seen = set() + for skill_id in skill_ids: + if skill_id is None: + continue + try: + skill_int = int(skill_id) + except (TypeError, ValueError): + continue + if skill_int not in seen: + seen.add(skill_int) + normalized.append(skill_int) + + if not normalized: + return [] + + existing = set(Skill.objects.filter(id__in=normalized).values_list('id', flat=True)) + missing = set(normalized) - existing + if missing: + raise ValidationError(f"Skills not found: {', '.join(str(mid) for mid in sorted(missing))}") + return normalized + + def _ensure_user_registration(self, user: User) -> Registration: + """Require that the user has a confirmed or attended registration for the event.""" + registration = Registration.objects.filter( + event=self.event, + user=user, + status__in=[ + Registration.StatusChoices.CONFIRMED, + Registration.StatusChoices.ATTENDED, + ], + is_deleted=False, + ).order_by('-registered_at').first() + if not registration: + raise ValidationError("User must have a confirmed or attended registration for this event.") + return registration + + def _load_font(self, size: int = 48): + try: + return ImageFont.truetype("arial.ttf", size) + except Exception: + return ImageFont.load_default() + + def _render_certificate_image(self, certificate: 'UserCertificate') -> None: + """Overlay user-specific text on the template image and attach it to the certificate.""" + if not self.image: + return + try: + template_path = self.image.path + except (AttributeError, ValueError): + return + + try: + base_image = Image.open(template_path).convert("RGB") + except FileNotFoundError: + return + + draw = ImageDraw.Draw(base_image) + font = self._load_font(size=48) + width, height = base_image.size + lines = [ + certificate.user.get_full_name() or certificate.user.email, + self.event.title, + f"Score: {certificate.score} ({certificate.score_label})", + timezone.localtime(certificate.issued_at).strftime('%Y-%m-%d'), + ] + margin = 40 + total_height = 0 + measurements = [] + for line in lines: + bbox = draw.textbbox((0, 0), line, font=font) + line_height = bbox[3] - bbox[1] + line_width = bbox[2] - bbox[0] + measurements.append((line, line_width, line_height)) + total_height += line_height + 10 + y = height - margin - total_height + for line, line_width, line_height in measurements: + x = (width - line_width) / 2 + draw.text((x, y), line, fill='black', font=font) + y += line_height + 10 + + buffer = BytesIO() + base_image.save(buffer, format='PNG') + buffer.seek(0) + filename = f"{self.event.slug}_{certificate.user_id}_{uuid4().hex}.png" + certificate.image.save(filename, ContentFile(buffer.read()), save=False) + certificate.save(update_fields=['image']) + + def award_certificate( + self, + *, + user: User, + title: str, + description: str = '', + score: Optional[int] = None, + skill_ids: Optional[Sequence[int]] = None, + issued_at=None, + expires_at=None, + ) -> 'UserCertificate': + """ + Create or update the certificate for a single user. + """ + self._ensure_user_registration(user) + resolved_score = self._validate_score(score) + resolved_skills = self._resolve_skill_ids(skill_ids) + issued_at = issued_at or timezone.now() + title = title or f"{self.event.title} Certificate" + description = description or '' + + certificate, _ = UserCertificate.objects.update_or_create( + user=user, + event=self.event, + defaults={ + 'template': self, + 'title': title, + 'description': description, + 'score': resolved_score, + 'issued_at': issued_at, + 'expires_at': expires_at, + }, + ) + + certificate.skills.set(resolved_skills) + self._render_certificate_image(certificate) + return certificate + + def generate_certificates( + self, + entries: Sequence[dict], + *, + default_title: Optional[str] = None, + default_description: Optional[str] = None, + ) -> list['UserCertificate']: + """ + Create certificates for a batch of users. + Entries expect dicts with at least `user_id` and `score`. + """ + if not entries: + raise ValidationError("Entries payload must contain at least one item.") + + user_ids = {entry.get('user_id') for entry in entries if entry.get('user_id') is not None} + if not user_ids: + raise ValidationError("No valid user IDs were provided.") + + users = {user.id: user for user in User.objects.filter(id__in=user_ids)} + missing = user_ids - users.keys() + if missing: + raise ValidationError(f"Users not found: {', '.join(str(uid) for uid in sorted(missing))}") + + certificates = [] + for entry in entries: + user = users.get(entry.get('user_id')) + if not user: + continue + certificate = self.award_certificate( + user=user, + title=entry.get('title') or default_title or f"{self.event.title} Certificate", + description=entry.get('description') or default_description or '', + score=entry.get('score'), + skill_ids=entry.get('skill_ids'), + issued_at=entry.get('issued_at'), + expires_at=entry.get('expires_at'), + ) + certificates.append(certificate) + return certificates + + +class UserCertificate(BaseModel): + SCORE_RANGES = [ + (0, 24, 'Fair'), + (25, 49, 'Good'), + (50, 74, 'Very Good'), + (75, 100, 'Perfect'), + ] + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name='certificates', + ) + event = models.ForeignKey( + 'events.Event', + on_delete=models.CASCADE, + related_name='user_certificates', + ) + template = models.ForeignKey( + CertificateTemplate, + on_delete=models.PROTECT, + related_name='awarded_certificates', + ) + certificate_id = models.UUIDField(default=uuid4, unique=True, editable=False) + code = models.CharField( + max_length=SHORT_CERTIFICATE_CODE_LENGTH, + unique=True, + editable=False, + default=_generate_certificate_code, + ) + title = models.CharField(max_length=255) + description = models.TextField(blank=True) + score = models.PositiveSmallIntegerField(default=0) + issued_at = models.DateTimeField(default=timezone.now) + expires_at = models.DateTimeField(null=True, blank=True) + image = models.ImageField( + upload_to='certificates/generated/', + null=True, + blank=True, + ) + skills = models.ManyToManyField( + Skill, + blank=True, + related_name='user_certificates', + help_text='Skills demonstrated on this certificate.', + ) + + class Meta: + unique_together = ('user', 'event') + ordering = ['-issued_at'] + indexes = [ + models.Index(fields=['user', 'event']), + models.Index(fields=['event', 'score']), + ] + + def __str__(self): + return f'{self.user} - {self.title} ({self.certificate_id})' + + @property + def score_label(self) -> str: + for lower, upper, label in self.SCORE_RANGES: + if lower <= self.score <= upper: + return label + return 'Unknown' + + @staticmethod + def _make_unique_code() -> str: + """Generate a short certificate code without collisions.""" + for _ in range(5): + candidate = _generate_certificate_code() + if not UserCertificate.objects.filter(code=candidate).exists(): + return candidate + raise RuntimeError("Unable to generate a unique certificate code.") + + def save(self, *args, **kwargs): + if not self.code or UserCertificate.objects.filter(code=self.code).exclude(pk=self.pk).exists(): + self.code = self._make_unique_code() + super().save(*args, **kwargs) diff --git a/backend/communications/admin.py b/backend/communications/admin.py new file mode 100644 index 0000000..d565666 --- /dev/null +++ b/backend/communications/admin.py @@ -0,0 +1,122 @@ +from django import forms +from django.contrib import admin +from django.utils import timezone + +from simplemde.widgets import SimpleMDEEditor +from import_export.admin import ImportExportModelAdmin + +from utils.admin import SoftDeleteListFilter, BaseModelAdmin +from communications.models import Announcement, NewsletterSubscription, PushNotificationDevice + + +class AnnouncementAdminForm(forms.ModelForm): + content = forms.CharField( + widget=SimpleMDEEditor(), + help_text="Announcement content in Markdown format with live preview" + ) + + class Meta: + model = Announcement + fields = '__all__' + + +@admin.register(Announcement) +class AnnouncementAdmin(BaseModelAdmin, ImportExportModelAdmin): + form = AnnouncementAdminForm + list_display = [ + 'title', 'announcement_type', 'priority', 'author', + 'is_published', 'publish_date', 'email_sent', 'push_sent', 'created_at' + ] + list_filter = [ + 'announcement_type', 'priority', 'is_published', + 'send_email', 'send_push', 'target_audience', + SoftDeleteListFilter, 'created_at' + ] + search_fields = ['title', 'content', 'author__username'] + readonly_fields = ['email_sent', 'push_sent', 'created_at', 'updated_at'] + + fieldsets = ( + ('Content', { + 'fields': ('title', 'content', 'author') + }), + ('Settings', { + 'fields': ('announcement_type', 'priority', 'target_audience', 'is_published', 'publish_date') + }), + ('Notifications', { + 'fields': ('send_email', 'send_push', 'email_sent', 'push_sent') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + actions = BaseModelAdmin.actions + ['publish_announcements', 'send_notifications'] + + def publish_announcements(self, request, queryset): + queryset.update(is_published=True, publish_date=timezone.now()) + self.message_user(request, f"{queryset.count()} announcements published.") + publish_announcements.short_description = "Publish selected announcements" + + def send_notifications(self, request, queryset): + # This will be implemented with Celery tasks + for announcement in queryset: + if announcement.send_email and not announcement.email_sent: + # Trigger email task + pass + if announcement.send_push and not announcement.push_sent: + # Trigger push notification task + pass + self.message_user(request, f"Notifications queued for {queryset.count()} announcements.") + send_notifications.short_description = "Send notifications for selected announcements" + + +@admin.register(NewsletterSubscription) +class NewsletterSubscriptionAdmin(BaseModelAdmin, ImportExportModelAdmin): + list_display = ['email', 'user', 'is_active', 'confirmed_at', 'created_at'] + list_filter = ['is_active', SoftDeleteListFilter, 'created_at', 'confirmed_at'] + search_fields = ['email', 'user__username', 'user__email'] + readonly_fields = ['confirmation_token', 'unsubscribe_token', 'created_at', 'updated_at'] + + fieldsets = ( + ('Subscription', { + 'fields': ('email', 'user', 'is_active', 'subscribed_categories') + }), + ('Confirmation', { + 'fields': ('confirmed_at', 'confirmation_token', 'unsubscribe_token') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) + + actions = BaseModelAdmin.actions + ['activate_subscriptions', 'deactivate_subscriptions'] + + def activate_subscriptions(self, request, queryset): + queryset.update(is_active=True) + self.message_user(request, f"{queryset.count()} subscriptions activated.") + activate_subscriptions.short_description = "Activate selected subscriptions" + + def deactivate_subscriptions(self, request, queryset): + queryset.update(is_active=False) + self.message_user(request, f"{queryset.count()} subscriptions deactivated.") + deactivate_subscriptions.short_description = "Deactivate selected subscriptions" + + +@admin.register(PushNotificationDevice) +class PushNotificationDeviceAdmin(BaseModelAdmin, ImportExportModelAdmin): + list_display = ['user', 'device_type', 'is_active', 'created_at'] + list_filter = ['device_type', 'is_active', SoftDeleteListFilter, 'created_at'] + search_fields = ['user__username', 'user__email', 'device_token'] + readonly_fields = ['created_at', 'updated_at'] + + fieldsets = ( + ('Device', { + 'fields': ('user', 'device_token', 'device_type', 'is_active') + }), + ('Timestamps', { + 'fields': ('created_at', 'updated_at'), + 'classes': ('collapse',) + }), + ) diff --git a/backend/communications/apps.py b/backend/communications/apps.py new file mode 100644 index 0000000..d956cd6 --- /dev/null +++ b/backend/communications/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CommunicationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'communications' + verbose_name = 'Communications' diff --git a/backend/communications/fixtures/communications.json b/backend/communications/fixtures/communications.json new file mode 100644 index 0000000..b93216d --- /dev/null +++ b/backend/communications/fixtures/communications.json @@ -0,0 +1,536 @@ +[ + { + "model": "communications.announcement", + "pk": 1, + "fields": { + "created_at": "2024-03-01T10:00:00Z", + "updated_at": "2024-03-01T10:00:00Z", + "is_deleted": false, + "title": "شروع ثبت‌نام کارگاه یادگیری ماشین", + "content": "# شروع ثبت‌نام کارگاه یادگیری ماشین\n\nبا سلام و احترام\n\nثبت‌نام کارگاه یادگیری ماشین پیشرفته از امروز آغاز شد.\n\n## جزئیات:\n- تاریخ: ۱۵ اسفند ۱۴۰۲\n- مدت: ۴ ساعت\n- هزینه: ۱۵۰ هزار تومان\n- ظرفیت: ۵۰ نفر\n\nبرای ثبت‌نام به وب‌سایت انجمن مراجعه کنید.", + "announcement_type": "event", + "priority": "high", + "author": 1, + "is_published": true, + "publish_date": "2024-03-01T10:00:00Z", + "send_email": true, + "send_push": true, + "email_sent": true, + "push_sent": true, + "target_audience": "all" + } + }, + { + "model": "communications.announcement", + "pk": 2, + "fields": { + "created_at": "2024-03-10T14:30:00Z", + "updated_at": "2024-03-10T14:30:00Z", + "is_deleted": false, + "title": "تغییر زمان مسابقه برنامه‌نویسی", + "content": "# تغییر زمان مسابقه برنامه‌نویسی\n\nبه اطلاع شرکت‌کنندگان محترم می‌رساند که زمان مسابقه برنامه‌نویسی بهاری به دلیل تعطیلات از ۲۲ اسفند به ۲۹ اسفند تغییر یافت.\n\nعذرخواهی بابت این تغییر و لطفاً برنامه‌ریزی خود را بر این اساس انجام دهید.", + "announcement_type": "urgent", + "priority": "urgent", + "author": 2, + "is_published": true, + "publish_date": "2024-03-10T14:30:00Z", + "send_email": true, + "send_push": true, + "email_sent": true, + "push_sent": true, + "target_audience": "all" + } + }, + { + "model": "communications.announcement", + "pk": 3, + "fields": { + "created_at": "2024-03-15T09:00:00Z", + "updated_at": "2024-03-15T09:00:00Z", + "is_deleted": false, + "title": "وبینار امنیت سایبری - رایگان", + "content": "# وبینار امنیت سایبری\n\nانجمن علمی مهندسی کامپیوتر برگزار می‌کند:\n\n**وبینار امنیت سایبری**\n\n- تاریخ: ۷ فروردین ۱۴۰۳\n- ساعت: ۱۹:۰۰ الی ۲۱:۰۰\n- مدرس: دکتر محمد رضایی\n- شرکت: رایگان\n\nلینک ورود یک ساعت قبل از شروع ارسال خواهد شد.", + "announcement_type": "event", + "priority": "normal", + "author": 5, + "is_published": true, + "publish_date": "2024-03-15T09:00:00Z", + "send_email": true, + "send_push": false, + "email_sent": true, + "push_sent": false, + "target_audience": "members" + } + }, + { + "model": "communications.announcement", + "pk": 4, + "fields": { + "created_at": "2024-03-20T11:15:00Z", + "updated_at": "2024-03-20T11:15:00Z", + "is_deleted": false, + "title": "فراخوان مقاله برای نشریه انجمن", + "content": "# فراخوان مقاله برای نشریه انجمن\n\nدانشجویان و اساتید محترم می‌توانند مقالات خود را در زمینه‌های زیر برای چاپ در نشریه انجمن ارسال کنند:\n\n## موضوعات:\n- هوش مصنوعی\n- امنیت سایبری\n- مهندسی نرم‌افزار\n- شبکه‌های کامپیوتری\n- علم داده\n\n## مهلت ارسال:\n۳۰ فروردین ۱۴۰۳\n\nایمیل ارسال: journal@cs-association.ac.ir", + "announcement_type": "academic", + "priority": "normal", + "author": 1, + "is_published": true, + "publish_date": "2024-03-20T11:15:00Z", + "send_email": true, + "send_push": false, + "email_sent": true, + "push_sent": false, + "target_audience": "all" + } + }, + { + "model": "communications.announcement", + "pk": 5, + "fields": { + "created_at": "2024-04-01T08:00:00Z", + "updated_at": "2024-04-01T08:00:00Z", + "is_deleted": false, + "title": "هکاتون هوش مصنوعی - ثبت‌نام آغاز شد", + "content": "# هکاتون هوش مصنوعی\n\nبزرگ‌ترین رویداد سال انجمن!\n\n## جزئیات:\n- تاریخ: ۳۰ فروردین تا ۲ اردیبهشت\n- مدت: ۴۸ ساعت\n- جایزه کل: ۲۰ میلیون تومان\n- ظرفیت: ۶۰ نفر (۲۰ تیم)\n\n## امکانات:\n- غذا و نوشیدنی رایگان\n- منتورینگ اساتید\n- فضای کار ۲۴ ساعته\n\nثبت‌نام تیمی (۳ نفره) الزامی است.", + "announcement_type": "event", + "priority": "high", + "author": 9, + "is_published": true, + "publish_date": "2024-04-01T08:00:00Z", + "send_email": true, + "send_push": true, + "email_sent": true, + "push_sent": true, + "target_audience": "all" + } + }, + { + "model": "communications.announcement", + "pk": 6, + "fields": { + "created_at": "2024-04-05T16:00:00Z", + "updated_at": "2024-04-05T16:00:00Z", + "is_deleted": false, + "title": "جلسه کمیته اجرایی انجمن", + "content": "# جلسه کمیته اجرایی انجمن\n\nاعضای محترم کمیته اجرایی\n\nجلسه ماهانه کمیته اجرایی:\n\n- تاریخ: ۱۰ اردیبهشت ۱۴۰۳\n- ساعت: ۱۴:۰۰\n- مکان: دفتر انجمن\n\n## دستور جلسه:\n1. بررسی گزارش مالی\n2. برنامه‌ریزی رویدادهای آتی\n3. بررسی درخواست‌های عضویت\n4. سایر موارد\n\nحضور همه اعضا الزامی است.", + "announcement_type": "general", + "priority": "normal", + "author": 1, + "is_published": true, + "publish_date": "2024-04-05T16:00:00Z", + "send_email": true, + "send_push": false, + "email_sent": true, + "push_sent": false, + "target_audience": "committee" + } + }, + { + "model": "communications.announcement", + "pk": 7, + "fields": { + "created_at": "2024-04-15T12:30:00Z", + "updated_at": "2024-04-15T12:30:00Z", + "is_deleted": false, + "title": "سمینار کارآفرینی فناوری", + "content": "# سمینار کارآفرینی فناوری\n\nبا حضور کارآفرینان موفق صنعت فناوری\n\n## سخنرانان:\n- دکتر علی احمدی (موسس تپسی)\n- خانم سارا محمدی (مدیرعامل کافه‌بازار)\n- مهندس رضا کریمی (سرمایه‌گذار)\n\n## موضوعات:\n- از ایده تا محصول\n- جذب سرمایه\n- چالش‌های استارتاپی\n- آینده فناوری در ایران\n\nشرکت رایگان - ظرفیت محدود", + "announcement_type": "event", + "priority": "high", + "author": 2, + "is_published": true, + "publish_date": "2024-04-15T12:30:00Z", + "send_email": true, + "send_push": true, + "email_sent": true, + "push_sent": true, + "target_audience": "all" + } + }, + { + "model": "communications.announcement", + "pk": 8, + "fields": { + "created_at": "2024-04-20T10:45:00Z", + "updated_at": "2024-04-20T10:45:00Z", + "is_deleted": false, + "title": "کارگاه DevOps - ثبت‌نام محدود", + "content": "# کارگاه DevOps و Docker\n\nآموزش عملی ابزارهای DevOps\n\n## محتوا:\n- Docker و Containerization\n- CI/CD Pipeline\n- Kubernetes مقدماتی\n- پروژه عملی\n\n## جزئیات:\n- تاریخ: ۱۴ اردیبهشت\n- مدت: ۸ ساعت\n- هزینه: ۳۰۰ هزار تومان\n- ظرفیت: ۲۵ نفر\n\n⚠️ ظرفیت بسیار محدود - عجله کنید!", + "announcement_type": "event", + "priority": "high", + "author": 8, + "is_published": true, + "publish_date": "2024-04-20T10:45:00Z", + "send_email": true, + "send_push": true, + "email_sent": true, + "push_sent": true, + "target_audience": "members" + } + }, + { + "model": "communications.announcement", + "pk": 9, + "fields": { + "created_at": "2024-04-25T13:20:00Z", + "updated_at": "2024-04-25T13:20:00Z", + "is_deleted": false, + "title": "مسابقه طراحی UI/UX - جوایز جذاب", + "content": "# مسابقه طراحی UI/UX\n\nفرصتی برای نمایش خلاقیت شما!\n\n## موضوع:\nطراحی اپلیکیشن مدیریت تسک دانشجویی\n\n## جوایز:\n- نفر اول: iPad Air\n- نفر دوم: AirPods Pro\n- نفر سوم: پاوربانک ۲۰۰۰۰ میلی‌آمپر\n\n## مهلت ارسال:\n۲۰ اردیبهشت ۱۴۰۳\n\nفایل‌های Figma یا Adobe XD قابل قبول هستند.", + "announcement_type": "event", + "priority": "normal", + "author": 12, + "is_published": true, + "publish_date": "2024-04-25T13:20:00Z", + "send_email": true, + "send_push": false, + "email_sent": true, + "push_sent": false, + "target_audience": "all" + } + }, + { + "model": "communications.announcement", + "pk": 10, + "fields": { + "created_at": "2024-05-01T15:00:00Z", + "updated_at": "2024-05-01T15:00:00Z", + "is_deleted": false, + "title": "نشست فارغ‌التحصیلان - دعوت ویژه", + "content": "# نشست فارغ‌التحصیلان\n\nدیدار با فارغ‌التحصیلان موفق\n\n## مهمانان ویژه:\n- دکتر حسن زارع (مدیر فنی گوگل)\n- مهندس مریم حسینی (بنیان‌گذار استارتاپ)\n- دکتر امیر قربانی (استاد MIT)\n\n## برنامه:\n- ۱۷:۰۰ - پذیرایی\n- ۱۸:۰۰ - سخنرانی‌ها\n- ۱۹:۳۰ - پرسش و پاسخ\n- ۲۰:۳۰ - ضیافت شام\n\nشرکت رایگان - ثبت‌نام الزامی", + "announcement_type": "event", + "priority": "normal", + "author": 5, + "is_published": true, + "publish_date": "2024-05-01T15:00:00Z", + "send_email": true, + "send_push": false, + "email_sent": true, + "push_sent": false, + "target_audience": "all" + } + }, + { + "model": "communications.newslettersubscription", + "pk": 1, + "fields": { + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z", + "is_deleted": false, + "email": "sara.mohammadi@student.ac.ir", + "user": 2, + "is_active": true, + "subscribed_categories": ["event", "academic", "general"], + "confirmed_at": "2024-01-15T10:30:00Z" + } + }, + { + "model": "communications.newslettersubscription", + "pk": 2, + "fields": { + "created_at": "2024-01-20T14:15:00Z", + "updated_at": "2024-01-20T14:15:00Z", + "is_deleted": false, + "email": "reza.karimi@student.ac.ir", + "user": 3, + "is_active": true, + "subscribed_categories": ["event", "urgent"], + "confirmed_at": "2024-01-20T14:15:00Z" + } + }, + { + "model": "communications.newslettersubscription", + "pk": 3, + "fields": { + "created_at": "2024-02-01T09:45:00Z", + "updated_at": "2024-02-01T09:45:00Z", + "is_deleted": false, + "email": "maryam.hosseini@student.ac.ir", + "user": 4, + "is_active": true, + "subscribed_categories": ["event", "academic"], + "confirmed_at": "2024-02-01T09:45:00Z" + } + }, + { + "model": "communications.newslettersubscription", + "pk": 4, + "fields": { + "created_at": "2024-02-05T16:20:00Z", + "updated_at": "2024-02-05T16:20:00Z", + "is_deleted": false, + "email": "hassan.zare@student.ac.ir", + "user": 5, + "is_active": true, + "subscribed_categories": ["general", "event", "academic", "urgent"], + "confirmed_at": "2024-02-05T16:20:00Z" + } + }, + { + "model": "communications.newslettersubscription", + "pk": 5, + "fields": { + "created_at": "2024-02-10T11:30:00Z", + "updated_at": "2024-02-10T11:30:00Z", + "is_deleted": false, + "email": "zahra.safari@student.ac.ir", + "user": 6, + "is_active": true, + "subscribed_categories": ["event", "academic"], + "confirmed_at": "2024-02-10T11:30:00Z" + } + }, + { + "model": "communications.newslettersubscription", + "pk": 6, + "fields": { + "created_at": "2024-02-15T13:45:00Z", + "updated_at": "2024-02-15T13:45:00Z", + "is_deleted": false, + "email": "fateme.moradi@student.ac.ir", + "user": 8, + "is_active": true, + "subscribed_categories": ["event"], + "confirmed_at": "2024-02-15T13:45:00Z" + } + }, + { + "model": "communications.newslettersubscription", + "pk": 7, + "fields": { + "created_at": "2024-02-20T08:15:00Z", + "updated_at": "2024-02-20T08:15:00Z", + "is_deleted": false, + "email": "amir.ghorbani@student.ac.ir", + "user": 9, + "is_active": true, + "subscribed_categories": ["general", "event", "academic"], + "confirmed_at": "2024-02-20T08:15:00Z" + } + }, + { + "model": "communications.newslettersubscription", + "pk": 8, + "fields": { + "created_at": "2024-02-25T15:30:00Z", + "updated_at": "2024-02-25T15:30:00Z", + "is_deleted": false, + "email": "nasrin.jafari@student.ac.ir", + "user": 10, + "is_active": true, + "subscribed_categories": ["academic", "event"], + "confirmed_at": "2024-02-25T15:30:00Z" + } + }, + { + "model": "communications.newslettersubscription", + "pk": 9, + "fields": { + "created_at": "2024-03-01T12:00:00Z", + "updated_at": "2024-03-01T12:00:00Z", + "is_deleted": false, + "email": "mehdi.bagheri@student.ac.ir", + "user": 11, + "is_active": true, + "subscribed_categories": ["event"], + "confirmed_at": "2024-03-01T12:00:00Z" + } + }, + { + "model": "communications.newslettersubscription", + "pk": 10, + "fields": { + "created_at": "2024-03-05T14:45:00Z", + "updated_at": "2024-03-05T14:45:00Z", + "is_deleted": false, + "email": "leila.mousavi@student.ac.ir", + "user": 12, + "is_active": true, + "subscribed_categories": ["event", "academic"], + "confirmed_at": "2024-03-05T14:45:00Z" + } + }, + { + "model": "communications.newslettersubscription", + "pk": 11, + "fields": { + "created_at": "2024-03-10T10:20:00Z", + "updated_at": "2024-03-10T10:20:00Z", + "is_deleted": false, + "email": "external.user1@gmail.com", + "user": null, + "is_active": true, + "subscribed_categories": ["event"], + "confirmed_at": "2024-03-10T10:20:00Z" + } + }, + { + "model": "communications.newslettersubscription", + "pk": 12, + "fields": { + "created_at": "2024-03-15T16:30:00Z", + "updated_at": "2024-03-15T16:30:00Z", + "is_deleted": false, + "email": "external.user2@yahoo.com", + "user": null, + "is_active": false, + "subscribed_categories": ["general"], + "confirmed_at": null + } + }, + { + "model": "communications.pushnotificationdevice", + "pk": 1, + "fields": { + "created_at": "2024-01-10T08:00:00Z", + "updated_at": "2024-01-10T08:00:00Z", + "is_deleted": false, + "user": 1, + "device_token": "web_push_token_admin_chrome", + "device_type": "web", + "is_active": true + } + }, + { + "model": "communications.pushnotificationdevice", + "pk": 2, + "fields": { + "created_at": "2024-01-15T12:30:00Z", + "updated_at": "2024-01-15T12:30:00Z", + "is_deleted": false, + "user": 2, + "device_token": "web_push_token_sara_firefox", + "device_type": "web", + "is_active": true + } + }, + { + "model": "communications.pushnotificationdevice", + "pk": 3, + "fields": { + "created_at": "2024-01-20T16:45:00Z", + "updated_at": "2024-01-20T16:45:00Z", + "is_deleted": false, + "user": 3, + "device_token": "web_push_token_reza_chrome", + "device_type": "web", + "is_active": true + } + }, + { + "model": "communications.pushnotificationdevice", + "pk": 4, + "fields": { + "created_at": "2024-02-01T11:20:00Z", + "updated_at": "2024-02-01T11:20:00Z", + "is_deleted": false, + "user": 4, + "device_token": "android_token_maryam_phone", + "device_type": "android", + "is_active": true + } + }, + { + "model": "communications.pushnotificationdevice", + "pk": 5, + "fields": { + "created_at": "2024-02-05T18:10:00Z", + "updated_at": "2024-02-05T18:10:00Z", + "is_deleted": false, + "user": 5, + "device_token": "web_push_token_hassan_edge", + "device_type": "web", + "is_active": true + } + }, + { + "model": "communications.pushnotificationdevice", + "pk": 6, + "fields": { + "created_at": "2024-02-10T13:25:00Z", + "updated_at": "2024-02-10T13:25:00Z", + "is_deleted": false, + "user": 6, + "device_token": "ios_token_zahra_iphone", + "device_type": "ios", + "is_active": true + } + }, + { + "model": "communications.pushnotificationdevice", + "pk": 7, + "fields": { + "created_at": "2024-02-15T15:40:00Z", + "updated_at": "2024-02-15T15:40:00Z", + "is_deleted": false, + "user": 8, + "device_token": "web_push_token_fateme_chrome", + "device_type": "web", + "is_active": true + } + }, + { + "model": "communications.pushnotificationdevice", + "pk": 8, + "fields": { + "created_at": "2024-02-20T10:15:00Z", + "updated_at": "2024-02-20T10:15:00Z", + "is_deleted": false, + "user": 9, + "device_token": "web_push_token_amir_firefox", + "device_type": "web", + "is_active": true + } + }, + { + "model": "communications.pushnotificationdevice", + "pk": 9, + "fields": { + "created_at": "2024-02-25T17:30:00Z", + "updated_at": "2024-02-25T17:30:00Z", + "is_deleted": false, + "user": 10, + "device_token": "android_token_nasrin_phone", + "device_type": "android", + "is_active": true + } + }, + { + "model": "communications.pushnotificationdevice", + "pk": 10, + "fields": { + "created_at": "2024-03-01T14:00:00Z", + "updated_at": "2024-03-01T14:00:00Z", + "is_deleted": false, + "user": 11, + "device_token": "web_push_token_mehdi_chrome", + "device_type": "web", + "is_active": true + } + }, + { + "model": "communications.pushnotificationdevice", + "pk": 11, + "fields": { + "created_at": "2024-03-05T16:50:00Z", + "updated_at": "2024-03-05T16:50:00Z", + "is_deleted": false, + "user": 12, + "device_token": "ios_token_leila_iphone", + "device_type": "ios", + "is_active": true + } + }, + { + "model": "communications.pushnotificationdevice", + "pk": 12, + "fields": { + "created_at": "2024-01-10T08:00:00Z", + "updated_at": "2024-03-10T12:00:00Z", + "is_deleted": false, + "user": 1, + "device_token": "android_token_admin_phone", + "device_type": "android", + "is_active": false + } + } +] diff --git a/backend/communications/migrations/0001_initial.py b/backend/communications/migrations/0001_initial.py new file mode 100644 index 0000000..51df34d --- /dev/null +++ b/backend/communications/migrations/0001_initial.py @@ -0,0 +1,78 @@ +# Generated by Django 5.2.5 on 2025-10-16 12:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Announcement', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('title', models.CharField(max_length=200, verbose_name='Title')), + ('content', models.TextField(verbose_name='Content')), + ('announcement_type', models.CharField(choices=[('general', 'General'), ('event', 'Event'), ('academic', 'Academic'), ('urgent', 'Urgent'), ('newsletter', 'Newsletter')], default='general', max_length=20, verbose_name='Type')), + ('priority', models.CharField(choices=[('low', 'Low'), ('normal', 'Normal'), ('high', 'High'), ('urgent', 'Urgent')], default='normal', max_length=10, verbose_name='Priority')), + ('is_published', models.BooleanField(default=False, verbose_name='Published')), + ('publish_date', models.DateTimeField(blank=True, null=True, verbose_name='Publish Date')), + ('send_email', models.BooleanField(default=False, verbose_name='Send Email Notification')), + ('send_push', models.BooleanField(default=False, verbose_name='Send Push Notification')), + ('email_sent', models.BooleanField(default=False, verbose_name='Email Sent')), + ('push_sent', models.BooleanField(default=False, verbose_name='Push Sent')), + ('target_audience', models.CharField(choices=[('all', 'All Users'), ('members', 'Members Only'), ('committee', 'Committee Only'), ('subscribers', 'Newsletter Subscribers Only')], default='all', max_length=20, verbose_name='Target Audience')), + ], + options={ + 'verbose_name': 'Announcement', + 'verbose_name_plural': 'Announcements', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='NewsletterSubscription', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('email', models.EmailField(max_length=254, unique=True, verbose_name='Email')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ('subscribed_categories', models.JSONField(blank=True, default=list, help_text='List of announcement types to receive', verbose_name='Subscribed Categories')), + ('confirmation_token', models.CharField(blank=True, max_length=100, verbose_name='Confirmation Token')), + ('confirmed_at', models.DateTimeField(blank=True, null=True, verbose_name='Confirmed At')), + ('unsubscribe_token', models.CharField(blank=True, max_length=100, verbose_name='Unsubscribe Token')), + ], + options={ + 'verbose_name': 'Newsletter Subscription', + 'verbose_name_plural': 'Newsletter Subscriptions', + 'ordering': ['-created_at'], + }, + ), + migrations.CreateModel( + name='PushNotificationDevice', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('device_token', models.TextField(verbose_name='Device Token')), + ('device_type', models.CharField(choices=[('web', 'Web'), ('android', 'Android'), ('ios', 'iOS')], max_length=10, verbose_name='Device Type')), + ('is_active', models.BooleanField(default=True, verbose_name='Active')), + ], + options={ + 'verbose_name': 'Push Notification Device', + 'verbose_name_plural': 'Push Notification Devices', + }, + ), + ] diff --git a/backend/communications/migrations/0002_initial.py b/backend/communications/migrations/0002_initial.py new file mode 100644 index 0000000..30b273d --- /dev/null +++ b/backend/communications/migrations/0002_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.5 on 2025-10-16 12:07 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('communications', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='announcement', + name='author', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='announcements', to=settings.AUTH_USER_MODEL, verbose_name='Author'), + ), + migrations.AddField( + model_name='newslettersubscription', + name='user', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='newsletter_subscription', to=settings.AUTH_USER_MODEL, verbose_name='User'), + ), + migrations.AddField( + model_name='pushnotificationdevice', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='push_devices', to=settings.AUTH_USER_MODEL, verbose_name='User'), + ), + migrations.AlterUniqueTogether( + name='pushnotificationdevice', + unique_together={('user', 'device_token')}, + ), + ] diff --git a/backend/communications/migrations/__init__.py b/backend/communications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/communications/models.py b/backend/communications/models.py new file mode 100644 index 0000000..dc018bf --- /dev/null +++ b/backend/communications/models.py @@ -0,0 +1,142 @@ +from django.db import models +from django.contrib.auth import get_user_model + +from utils.models import BaseModel + +User = get_user_model() + + +class AnnouncementType(models.TextChoices): + GENERAL = 'general', 'General' + EVENT = 'event', 'Event' + ACADEMIC = 'academic', 'Academic' + URGENT = 'urgent', 'Urgent' + NEWSLETTER = 'newsletter', 'Newsletter' + + +class AnnouncementPriority(models.TextChoices): + LOW = 'low', 'Low' + NORMAL = 'normal', 'Normal' + HIGH = 'high', 'High' + URGENT = 'urgent', 'Urgent' + + +class Announcement(BaseModel): + title = models.CharField(max_length=200, verbose_name='Title') + content = models.TextField(verbose_name='Content') + announcement_type = models.CharField( + max_length=20, + choices=AnnouncementType.choices, + default=AnnouncementType.GENERAL, + verbose_name='Type' + ) + priority = models.CharField( + max_length=10, + choices=AnnouncementPriority.choices, + default=AnnouncementPriority.NORMAL, + verbose_name='Priority' + ) + author = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='announcements', + verbose_name='Author' + ) + is_published = models.BooleanField(default=False, verbose_name='Published') + publish_date = models.DateTimeField(null=True, blank=True, verbose_name='Publish Date') + send_email = models.BooleanField(default=False, verbose_name='Send Email Notification') + send_push = models.BooleanField(default=False, verbose_name='Send Push Notification') + email_sent = models.BooleanField(default=False, verbose_name='Email Sent') + push_sent = models.BooleanField(default=False, verbose_name='Push Sent') + target_audience = models.CharField( + max_length=20, + choices=[ + ('all', 'All Users'), + ('members', 'Members Only'), + ('committee', 'Committee Only'), + ('subscribers', 'Newsletter Subscribers Only'), + ], + default='all', + verbose_name='Target Audience' + ) + + class Meta: + verbose_name = 'Announcement' + verbose_name_plural = 'Announcements' + ordering = ['-created_at'] + + def __str__(self): + return self.title + + @property + def content_html(self): + """Convert markdown content to HTML""" + import markdown + return markdown.markdown(self.content) + + +class NewsletterSubscription(BaseModel): + email = models.EmailField(unique=True, verbose_name='Email') + user = models.OneToOneField( + User, + on_delete=models.CASCADE, + null=True, + blank=True, + related_name='newsletter_subscription', + verbose_name='User' + ) + is_active = models.BooleanField(default=True, verbose_name='Active') + subscribed_categories = models.JSONField( + default=list, + blank=True, + verbose_name='Subscribed Categories', + help_text='List of announcement types to receive' + ) + confirmation_token = models.CharField(max_length=100, blank=True, verbose_name='Confirmation Token') + confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name='Confirmed At') + unsubscribe_token = models.CharField(max_length=100, blank=True, verbose_name='Unsubscribe Token') + + class Meta: + verbose_name = 'Newsletter Subscription' + verbose_name_plural = 'Newsletter Subscriptions' + ordering = ['-created_at'] + + def __str__(self): + return self.email + + def save(self, *args, **kwargs): + if not self.confirmation_token: + import uuid + self.confirmation_token = str(uuid.uuid4()) + if not self.unsubscribe_token: + import uuid + self.unsubscribe_token = str(uuid.uuid4()) + super().save(*args, **kwargs) + + +class PushNotificationDevice(BaseModel): + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name='push_devices', + verbose_name='User' + ) + device_token = models.TextField(verbose_name='Device Token') + device_type = models.CharField( + max_length=10, + choices=[ + ('web', 'Web'), + ('android', 'Android'), + ('ios', 'iOS'), + ], + verbose_name='Device Type' + ) + is_active = models.BooleanField(default=True, verbose_name='Active') + + class Meta: + verbose_name = 'Push Notification Device' + verbose_name_plural = 'Push Notification Devices' + unique_together = ['user', 'device_token'] + + def __str__(self): + return f"{self.user.username} - {self.device_type}" diff --git a/backend/communications/push_notifications.py b/backend/communications/push_notifications.py new file mode 100644 index 0000000..e7fd931 --- /dev/null +++ b/backend/communications/push_notifications.py @@ -0,0 +1,194 @@ +from django.conf import settings + +import json +import logging +from typing import List, Dict, Any, Optional +from pywebpush import webpush, WebPushException + +from communications.models import PushNotificationDevice +from events.models import Registration + +logger = logging.getLogger(__name__) + + +class PushNotificationService: + """Service for handling web push notifications""" + + def __init__(self): + self.vapid_private_key = getattr(settings, 'VAPID_PRIVATE_KEY', None) + self.vapid_public_key = getattr(settings, 'VAPID_PUBLIC_KEY', None) + self.vapid_claims = getattr(settings, 'VAPID_CLAIMS', {}) + + def send_notification( + self, + subscription_info: Dict[str, Any], + data: Dict[str, Any], + ttl: int = 86400 + ) -> bool: + """ + Send a push notification to a single device + + Args: + subscription_info: Device subscription information + data: Notification payload + ttl: Time to live in seconds (default 24 hours) + + Returns: + bool: True if successful, False otherwise + """ + try: + webpush( + subscription_info=subscription_info, + data=json.dumps(data), + vapid_private_key=self.vapid_private_key, + vapid_claims=self.vapid_claims, + ttl=ttl + ) + return True + except WebPushException as e: + logger.error(f"Push notification failed: {e}") + if e.response and e.response.status_code in [410, 413]: + # Subscription is no longer valid, should be removed + self._remove_invalid_subscription(subscription_info) + return False + except Exception as e: + logger.error(f"Unexpected error sending push notification: {e}") + return False + + def send_to_multiple( + self, + devices: List[PushNotificationDevice], + data: Dict[str, Any], + ttl: int = 86400 + ) -> Dict[str, int]: + """ + Send push notification to multiple devices + + Args: + devices: List of PushNotificationDevice objects + data: Notification payload + ttl: Time to live in seconds + + Returns: + dict: Statistics of sent/failed notifications + """ + stats = {'sent': 0, 'failed': 0} + + for device in devices: + subscription_info = { + 'endpoint': device.endpoint, + 'keys': { + 'p256dh': device.p256dh_key, + 'auth': device.auth_key + } + } + + if self.send_notification(subscription_info, data, ttl): + stats['sent'] += 1 + else: + stats['failed'] += 1 + + return stats + + def send_announcement_notification( + self, + announcement, + devices: Optional[List[PushNotificationDevice]] = None + ) -> Dict[str, int]: + """ + Send push notification for an announcement + + Args: + announcement: Announcement model instance + devices: Optional list of specific devices to send to + + Returns: + dict: Statistics of sent/failed notifications + """ + if devices is None: + # Get devices based on announcement audience + if announcement.audience == 'all': + devices = PushNotificationDevice.objects.filter(is_active=True) + elif announcement.audience == 'members': + devices = PushNotificationDevice.objects.filter( + user__is_member=True, + is_active=True + ) + elif announcement.audience == 'committee': + devices = PushNotificationDevice.objects.filter( + user__is_committee_member=True, + is_active=True + ) + else: + devices = PushNotificationDevice.objects.none() + + # Prepare notification data + data = { + 'title': announcement.title, + 'body': announcement.content[:100] + '...' if len(announcement.content) > 100 else announcement.content, + 'icon': '/static/images/logo.png', + 'badge': '/static/images/badge.png', + 'data': { + 'type': 'announcement', + 'id': announcement.id, + 'url': f'/announcements/{announcement.id}/' + } + } + + return self.send_to_multiple(devices, data) + + def send_event_reminder_notification( + self, + event, + devices: Optional[List[PushNotificationDevice]] = None + ) -> Dict[str, int]: + """ + Send push notification for event reminder + + Args: + event: Event model instance + devices: Optional list of specific devices to send to + + Returns: + dict: Statistics of sent/failed notifications + """ + if devices is None: + # Get devices of registered users + registered_users = Registration.objects.filter( + event=event, + status='confirmed' + ).values_list('user_id', flat=True) + + devices = PushNotificationDevice.objects.filter( + user_id__in=registered_users, + is_active=True + ) + + # Prepare notification data + data = { + 'title': f'Event Reminder: {event.title}', + 'body': f'Your event "{event.title}" starts in 24 hours!', + 'icon': '/static/images/logo.png', + 'badge': '/static/images/badge.png', + 'data': { + 'type': 'event_reminder', + 'id': event.id, + 'url': f'/events/{event.id}/' + } + } + + return self.send_to_multiple(devices, data) + + def _remove_invalid_subscription(self, subscription_info: Dict[str, Any]): + """Remove invalid subscription from database""" + try: + PushNotificationDevice.objects.filter( + endpoint=subscription_info['endpoint'] + ).delete() + logger.info(f"Removed invalid subscription: {subscription_info['endpoint']}") + except Exception as e: + logger.error(f"Error removing invalid subscription: {e}") + + +# Create a singleton instance +push_service = PushNotificationService() diff --git a/backend/communications/resources.py b/backend/communications/resources.py new file mode 100644 index 0000000..6d640cc --- /dev/null +++ b/backend/communications/resources.py @@ -0,0 +1,56 @@ +from django.contrib.auth import get_user_model + +from import_export import resources, fields +from import_export.widgets import ForeignKeyWidget + +from communications.models import Announcement, NewsletterSubscription, PushNotificationDevice + +User = get_user_model() + + +class AnnouncementResource(resources.ModelResource): + author = fields.Field( + column_name='author', + attribute='author', + widget=ForeignKeyWidget(User, 'username') + ) + + class Meta: + model = Announcement + fields = ( + 'id', 'title', 'content', 'announcement_type', 'priority', + 'author', 'is_published', 'publish_date', 'send_email', 'send_push', + 'target_audience', 'created_at', 'updated_at' + ) + export_order = fields + + +class NewsletterSubscriptionResource(resources.ModelResource): + user = fields.Field( + column_name='user', + attribute='user', + widget=ForeignKeyWidget(User, 'username') + ) + + class Meta: + model = NewsletterSubscription + fields = ( + 'id', 'email', 'user', 'is_active', 'subscribed_categories', + 'confirmed_at', 'created_at', 'updated_at' + ) + export_order = fields + + +class PushNotificationDeviceResource(resources.ModelResource): + user = fields.Field( + column_name='user', + attribute='user', + widget=ForeignKeyWidget(User, 'username') + ) + + class Meta: + model = PushNotificationDevice + fields = ( + 'id', 'user', 'device_type', 'is_active', 'created_at', 'updated_at' + ) + export_order = fields diff --git a/backend/communications/tasks.py b/backend/communications/tasks.py new file mode 100644 index 0000000..6d69840 --- /dev/null +++ b/backend/communications/tasks.py @@ -0,0 +1,278 @@ +from django.utils import timezone +from django.contrib.auth import get_user_model + +import logging +from celery import shared_task +from datetime import timedelta + +from events.models import Event, Registration +from communications.models import Announcement, NewsletterSubscription +from communications.utils import send_announcement_email, send_event_reminder, get_announcement_recipients +from communications.push_notifications import push_service + +User = get_user_model() +logger = logging.getLogger(__name__) +SYSTEM_USER_ID = 1 + + +@shared_task(bind=True, max_retries=3) +def send_announcement_notifications(self, announcement_id): + """Send email and push notifications for an announcement""" + try: + announcement = Announcement.objects.get(id=announcement_id) + + # Send email notifications + if announcement.send_email and not announcement.email_sent: + recipients = get_announcement_recipients(announcement) + if recipients: + success = send_announcement_email(announcement, recipients) + if success: + announcement.email_sent = True + announcement.save() + logger.info(f"Email notifications sent for announcement {announcement.id}") + + # Send push notifications + if announcement.send_push and not announcement.push_sent: + sent_count = push_service.send_announcement_notification(announcement) + if sent_count > 0: + announcement.push_sent = True + announcement.save() + logger.info(f"Push notifications sent to {sent_count} devices for announcement {announcement.id}") + + return f"Notifications sent for announcement: {announcement.title}" + + except Announcement.DoesNotExist: + logger.error(f"Announcement {announcement_id} not found") + return f"Announcement {announcement_id} not found" + except Exception as exc: + logger.error(f"Failed to send announcement notifications: {exc}") + raise self.retry(exc=exc, countdown=60) + + +@shared_task(bind=True, max_retries=3) +def send_newsletter_confirmation_task(self, subscription_id): + """Send newsletter confirmation email""" + try: + from .utils import send_newsletter_confirmation + + subscription = NewsletterSubscription.objects.get(id=subscription_id) + success = send_newsletter_confirmation(subscription) + + if success: + logger.info(f"Newsletter confirmation sent to {subscription.email}") + return f"Newsletter confirmation sent to {subscription.email}" + else: + raise Exception("Failed to send newsletter confirmation") + + except NewsletterSubscription.DoesNotExist: + logger.error(f"Newsletter subscription {subscription_id} not found") + return f"Newsletter subscription {subscription_id} not found" + except Exception as exc: + logger.error(f"Failed to send newsletter confirmation: {exc}") + raise self.retry(exc=exc, countdown=60) + + +@shared_task +def send_event_reminders(): + """Send reminders for events starting about 24 hours from now within a 30-minute window.""" + try: + reminder_target = timezone.now() + timedelta(hours=24) + window = timedelta(minutes=30) + start_range = reminder_target - window + end_range = reminder_target + window + + events = Event.objects.filter( + start_time__range=(start_range, end_range), + status='published', + is_deleted=False + ) + + total_sent = 0 + + for event in events: + # Get confirmed registrations + registrations = Registration.objects.filter( + event=event, + status='confirmed', + is_deleted=False + ).select_related('user') + + for registration in registrations: + try: + # Send email reminder + send_event_reminder(event, registration.user) + + # Send push notification reminder + push_service.send_event_reminder_notification(event, registration.user) + + total_sent += 1 + + except Exception as e: + logger.error(f"Failed to send reminder to {registration.user.email}: {str(e)}") + + logger.info(f"Event reminders sent to {total_sent} users") + return f"Event reminders sent to {total_sent} users" + + except Exception as exc: + logger.error(f"Failed to send event reminders: {exc}") + raise exc + + +@shared_task +def send_weekly_newsletter(): + """Send the weekly newsletter as the system user with recent announcements and upcoming events.""" + try: + # Get active newsletter subscribers + subscribers = NewsletterSubscription.objects.filter( + is_active=True, + confirmed_at__isnull=False, + is_deleted=False + ) + + if not subscribers.exists(): + logger.info("No active newsletter subscribers found") + return "No active newsletter subscribers found" + + # Get recent announcements (last 7 days) + week_ago = timezone.now() - timedelta(days=7) + recent_announcements = Announcement.objects.filter( + is_published=True, + publish_date__gte=week_ago, + announcement_type__in=['general', 'academic', 'newsletter'], + is_deleted=False + ).order_by('-publish_date')[:5] + + # Get upcoming events (next 14 days) + two_weeks_ahead = timezone.now() + timedelta(days=14) + upcoming_events = Event.objects.filter( + start_time__range=(timezone.now(), two_weeks_ahead), + status='published', + is_deleted=False + ).order_by('start_time')[:5] + + newsletter_content = f""" +# Weekly Newsletter - {timezone.now().strftime('%B %d, %Y')} + +## Recent Announcements +""" + + for announcement in recent_announcements: + newsletter_content += f"- **{announcement.title}** ({announcement.publish_date.strftime('%B %d')})\n" + + newsletter_content += "\n## Upcoming Events\n" + + for event in upcoming_events: + newsletter_content += f"- **{event.title}** - {event.start_time.strftime('%B %d, %Y at %I:%M %p')}\n" + + if not recent_announcements.exists() and not upcoming_events.exists(): + newsletter_content += "\nNo recent announcements or upcoming events this week." + + newsletter = Announcement.objects.create( + title=f"Weekly Newsletter - {timezone.now().strftime('%B %d, %Y')}", + content=newsletter_content, + announcement_type='newsletter', + priority='normal', + author_id=SYSTEM_USER_ID, + is_published=True, + publish_date=timezone.now(), + send_email=True, + target_audience='subscribers' + ) + + # Send to subscribers + subscriber_emails = list(subscribers.values_list('email', flat=True)) + success = send_announcement_email(newsletter, subscriber_emails) + + if success: + newsletter.email_sent = True + newsletter.save() + logger.info(f"Weekly newsletter sent to {len(subscriber_emails)} subscribers") + return f"Weekly newsletter sent to {len(subscriber_emails)} subscribers" + else: + raise Exception("Failed to send weekly newsletter") + + except Exception as exc: + logger.error(f"Failed to send weekly newsletter: {exc}") + raise exc + + +@shared_task +def cleanup_expired_tokens(): + """Clean up expired newsletter confirmation tokens""" + try: + # Remove unconfirmed subscriptions older than 7 days + week_ago = timezone.now() - timedelta(days=7) + expired_subscriptions = NewsletterSubscription.objects.filter( + confirmed_at__isnull=True, + created_at__lt=week_ago + ) + + count = expired_subscriptions.count() + expired_subscriptions.delete() + + logger.info(f"Cleaned up {count} expired newsletter subscriptions") + return f"Cleaned up {count} expired newsletter subscriptions" + + except Exception as exc: + logger.error(f"Failed to cleanup expired tokens: {exc}") + raise exc + + +@shared_task +def send_bulk_announcement(announcement_id, recipient_emails): + """Send announcement to a specific list of recipients""" + try: + announcement = Announcement.objects.get(id=announcement_id) + + # Split recipients into batches to avoid overwhelming the email server + batch_size = 50 + total_sent = 0 + + for i in range(0, len(recipient_emails), batch_size): + batch = recipient_emails[i:i + batch_size] + success = send_announcement_email(announcement, batch) + + if success: + total_sent += len(batch) + logger.info(f"Sent announcement to batch of {len(batch)} recipients") + + # Small delay between batches + import time + time.sleep(1) + + logger.info(f"Bulk announcement sent to {total_sent} recipients") + return f"Bulk announcement sent to {total_sent} recipients" + + except Exception as exc: + logger.error(f"Failed to send bulk announcement: {exc}") + raise exc + + +@shared_task +def process_scheduled_announcements(): + """Process announcements scheduled for publication""" + try: + now = timezone.now() + + # Get announcements scheduled for publication + scheduled_announcements = Announcement.objects.filter( + is_published=True, + publish_date__lte=now, + email_sent=False, + send_email=True, + is_deleted=False + ) + + processed_count = 0 + + for announcement in scheduled_announcements: + # Send notifications + send_announcement_notifications.delay(announcement.id) + processed_count += 1 + + logger.info(f"Processed {processed_count} scheduled announcements") + return f"Processed {processed_count} scheduled announcements" + + except Exception as exc: + logger.error(f"Failed to process scheduled announcements: {exc}") + raise exc diff --git a/backend/communications/utils.py b/backend/communications/utils.py new file mode 100644 index 0000000..c58b919 --- /dev/null +++ b/backend/communications/utils.py @@ -0,0 +1,140 @@ +from django.contrib.auth import get_user_model +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.utils.html import strip_tags +from django.conf import settings + +import logging + +from communications.models import NewsletterSubscription + +logger = logging.getLogger(__name__) + + +def send_announcement_email(announcement, recipients): + """Send announcement email to recipients""" + try: + template_name = f'emails/announcement_email.html' + + context = { + 'announcement': announcement, + 'unsubscribe_url': f"{settings.FRONTEND_ROOT}newsletter/unsubscribe/", + 'manage_subscription_url': f"{settings.FRONTEND_ROOT}newsletter/manage-subscription", + } + + html_message = render_to_string(template_name, context) + plain_message = strip_tags(html_message) + + subject = f"انجمن علمی کامپیوتر گیلان | {announcement.title}" + + send_mail( + subject=subject, + message=plain_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=recipients, + html_message=html_message, + fail_silently=False, + ) + + logger.info(f"Announcement email sent to {len(recipients)} recipients") + return True + + except Exception as e: + logger.error(f"Failed to send announcement email: {str(e)}") + return False + + +def send_newsletter_confirmation(subscription): + """Send newsletter confirmation email""" + try: + template_name = f'emails/newsletter_confirmation.html' + + confirmation_url = f"{settings.FRONTEND_ROOT}confirm-subscription/{subscription.confirmation_token}" + + context = { + 'subscription': subscription, + 'confirmation_url': confirmation_url, + } + + html_message = render_to_string(template_name, context) + plain_message = strip_tags(html_message) + + subject = "تأیید اشتراک خبرنامه" + send_mail( + subject=subject, + message=plain_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[subscription.email], + html_message=html_message, + fail_silently=False, + ) + + logger.info(f"Newsletter confirmation sent to {subscription.email}") + return True + + except Exception as e: + logger.error(f"Failed to send newsletter confirmation: {str(e)}") + return False + + +def send_event_reminder(event, user): + """Send event reminder email""" + try: + template_name = f'emails/event_reminder.html' + + event_url = f"{settings.FRONTEND_ROOT}events/{event.slug}" + + context = { + 'event': event, + 'user': user, + 'event_url': event_url, + } + + html_message = render_to_string(template_name, context) + plain_message = strip_tags(html_message) + + subject = f"یادآوری رویداد: {event.title}" + + send_mail( + subject=subject, + message=plain_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) + + logger.info(f"Event reminder sent to {user.email} for event {event.title}") + return True + + except Exception as e: + logger.error(f"Failed to send event reminder: {str(e)}") + return False + + +def get_announcement_recipients(announcement): + """Get list of email addresses based on announcement target audience""" + + User = get_user_model() + recipients = [] + + if announcement.target_audience == 'all': + # All users with email + recipients = list(User.objects.filter(email__isnull=False).values_list('email', flat=True)) + + elif announcement.target_audience == 'members': + # Only members (users with is_member=True) + recipients = list(User.objects.filter(is_member=True, email__isnull=False).values_list('email', flat=True)) + + elif announcement.target_audience == 'committee': + # Only committee members + recipients = list(User.objects.filter(is_committee=True, email__isnull=False).values_list('email', flat=True)) + + elif announcement.target_audience == 'subscribers': + # Only newsletter subscribers + recipients = list(NewsletterSubscription.objects.filter( + is_active=True, + confirmed_at__isnull=False + ).values_list('email', flat=True)) + + return recipients diff --git a/backend/config/__init__.py b/backend/config/__init__.py new file mode 100644 index 0000000..bfed38d --- /dev/null +++ b/backend/config/__init__.py @@ -0,0 +1,3 @@ +from config.services.celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/backend/config/asgi.py b/backend/config/asgi.py new file mode 100644 index 0000000..e5167bb --- /dev/null +++ b/backend/config/asgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') + +application = get_asgi_application() diff --git a/backend/config/services/celery.py b/backend/config/services/celery.py new file mode 100644 index 0000000..39255f4 --- /dev/null +++ b/backend/config/services/celery.py @@ -0,0 +1,56 @@ +"""Celery application configuration and scheduling.""" + +import os + +from celery import Celery +from celery.schedules import crontab +from decouple import config + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') + +app = Celery('config') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() + +app.conf.update( + broker_url=config('REDIS_URL', default='redis://localhost:6379/0'), + result_backend=config('REDIS_URL', default='redis://localhost:6379/0'), + task_serializer='json', + accept_content=['json'], + result_serializer='json', + timezone='UTC', + enable_utc=True, + task_track_started=True, + task_time_limit=30 * 60, + task_soft_time_limit=60, + worker_prefetch_multiplier=1, + worker_max_tasks_per_child=1000, +) + +app.conf.beat_schedule = { + 'send-event-reminders': { + 'task': 'communications.tasks.send_event_reminders', + 'schedule': crontab(minute=0, hour='*/1'), + 'description': 'Runs hourly to notify about upcoming events.', + }, + 'send-weekly-newsletter': { + 'task': 'communications.tasks.send_weekly_newsletter', + 'schedule': crontab(hour=9, minute=0, day_of_week=1), + 'description': 'Runs every Monday at 09:00 UTC.', + }, + 'cleanup-expired-tokens': { + 'task': 'communications.tasks.cleanup_expired_tokens', + 'schedule': crontab(hour=2, minute=0), + 'description': 'Runs daily at 02:00 UTC.', + }, + 'process-scheduled-announcements': { + 'task': 'communications.tasks.process_scheduled_announcements', + 'schedule': crontab(minute='*/15'), + 'description': 'Runs every 15 minutes to dispatch scheduled announcements.', + }, +} + +EMAIL_TIMEOUT_SECONDS = 10 + +CELERY_TASK_SOFT_TIME_LIMIT = 20 +CELERY_TASK_TIME_LIMIT = 30 diff --git a/backend/config/services/location.py b/backend/config/services/location.py new file mode 100644 index 0000000..0bf0ace --- /dev/null +++ b/backend/config/services/location.py @@ -0,0 +1,14 @@ +"""Configuration for Django location fields backed by OpenStreetMap.""" + +DEFAULT_MAP_CENTER = [37.0629098, 50.4232464] + +LOCATION_FIELD = { + 'map.provider': 'openstreetmap', + 'map.zoom': 13, + 'map.center': DEFAULT_MAP_CENTER, + 'map.language': 'fa', + 'search.provider': 'nominatim', + 'search.url': 'https://nominatim.openstreetmap.org/search/', + 'search.params': {'format': 'json', 'addressdetails': 1}, + 'search.headers': {'User-Agent': 'Django CS Association App'}, +} diff --git a/backend/config/services/notifications.py b/backend/config/services/notifications.py new file mode 100644 index 0000000..350cc5d --- /dev/null +++ b/backend/config/services/notifications.py @@ -0,0 +1,12 @@ +from decouple import config + +# Added VAPID configuration for web push notifications +# VAPID Configuration for Web Push Notifications +VAPID_PUBLIC_KEY = config('VAPID_PUBLIC_KEY', default='') +VAPID_PRIVATE_KEY = config('VAPID_PRIVATE_KEY', default='') +VAPID_CLAIMS = { + "sub": config('VAPID_SUBJECT', default='mailto:admin@csassociation.com') +} + +# Site URL for push notification links +SITE_URL = config('SITE_URL', default='http://localhost:8000') diff --git a/backend/config/services/unfold.py b/backend/config/services/unfold.py new file mode 100644 index 0000000..1024445 --- /dev/null +++ b/backend/config/services/unfold.py @@ -0,0 +1,94 @@ +from django.conf import settings +from django.templatetags.static import static + +# Django Unfold Configuration +UNFOLD = { + "SITE_TITLE": "GuilanCE Association Admin", + "SITE_HEADER": "GuilanCE Association", + "SITE_URL": "/", + "SITE_ICON": lambda request: static("img/logo.png"), + # "SITE_LOGO": lambda request: static("img/logo.png"), + "SITE_SYMBOL": "speed", + "SHOW_HISTORY": True, + "SHOW_VIEW_ON_SITE": True, + # "SHOW_BACK_BUTTON": True, + "ENVIRONMENT": "config.services.unfold.environment_callback", + "LOGIN": { + "image": lambda request: request.build_absolute_uri("/static/images/login-bg.jpg"), + "redirect_after": lambda request: request.build_absolute_uri("/admin/"), + }, + "STYLES": [ + lambda request: request.build_absolute_uri("/static/css/styles.css"), + ], + "SCRIPTS": [ + lambda request: request.build_absolute_uri("/static/js/scripts.js"), + ], + "COLORS": { + "primary": { + "50": "250 245 255", + "100": "243 232 255", + "200": "233 213 255", + "300": "216 180 254", + "400": "196 144 254", + "500": "168 85 247", + "600": "147 51 234", + "700": "126 34 206", + "800": "107 33 168", + "900": "88 28 135", + }, + }, + "EXTENSIONS": { + "modeltranslation": { + "flags": { + "en": "🇺🇸", + "fa": "🇮🇷", + }, + }, + }, + "SIDEBAR": { + "show_search": True, + "show_all_applications": True, + "navigation": [ + { + "title": "Navigation", + "separator": True, + "items": [ + { + "title": "Dashboard", + "icon": "dashboard", + "link": lambda request: request.build_absolute_uri("/admin/"), + # "badge": 3 + }, + { + "title": "Users", + "icon": "account_circle", + "link": lambda request: request.build_absolute_uri("/admin/users/user/"), + }, + { + "title": "Blog", + "icon": "post", + "link": lambda request: request.build_absolute_uri("/admin/blog/"), + }, + { + "title": "Events", + "icon": "event", + "link": lambda request: request.build_absolute_uri("/admin/events/"), + }, + { + "title": "Gallery", + "icon": "filter", + "link": lambda request: request.build_absolute_uri("/admin/gallery/gallery/"), + }, + { + "title": "Communications", + "icon": "call", + "link": lambda request: request.build_absolute_uri("/admin/communications/"), + }, + ], + }, + ], + }, +} + +def environment_callback(request): + return ["Development", "warning"] if settings.DEBUG else ["Production", "success"] diff --git a/backend/config/services/zarinpal.py b/backend/config/services/zarinpal.py new file mode 100644 index 0000000..51ec1fa --- /dev/null +++ b/backend/config/services/zarinpal.py @@ -0,0 +1,10 @@ +from decouple import config + +ZARINPAL_MERCHANT_ID = config('ZARINPAL_MERCHANT_ID', default='') +ZARINPAL_USE_SANDBOX = config('ZARINPAL_USE_SANDBOX', default=False, cast=bool) + +ZARINPAL_API_BASE = "https://sandbox.zarinpal.com" if ZARINPAL_USE_SANDBOX else "https://payment.zarinpal.com" +ZARINPAL_REQUEST_URL = f"{ZARINPAL_API_BASE}/pg/v4/payment/request.json" +ZARINPAL_VERIFY_URL = f"{ZARINPAL_API_BASE}/pg/v4/payment/verify.json" +ZARINPAL_STARTPAY = f"{ZARINPAL_API_BASE}/pg/StartPay/" +ZARINPAL_CALLBACK_URL = config('ZARINPAL_CALLBACK_URL', default='http://localhost:8000/api/payments/callback') diff --git a/backend/config/settings/base.py b/backend/config/settings/base.py new file mode 100644 index 0000000..8fc0a2c --- /dev/null +++ b/backend/config/settings/base.py @@ -0,0 +1,233 @@ +from decouple import config +from pathlib import Path +import os + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +SECRET_KEY = config('SECRET_KEY') + +DEBUG = config('DEBUG', default=False, cast=bool) + +ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='').split(',') + +DJANGO_APPS = [ + 'unfold', + 'unfold.contrib.filters', + 'unfold.contrib.forms', + 'unfold.contrib.import_export', + 'unfold.contrib.location_field', + + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', +] + +THIRD_PARTY_APPS = [ + 'corsheaders', + 'import_export', + 'simplemde', + 'location_field', + "django_prometheus", +] + +LOCAL_APPS = [ + 'users', + 'blog', + 'gallery', + 'events', + 'certificates', + 'communications', + 'payments', + 'utils', +] + +INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS + +MIDDLEWARE = [ + "django_prometheus.middleware.PrometheusBeforeMiddleware", + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'whitenoise.middleware.WhiteNoiseMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + "django_prometheus.middleware.PrometheusAfterMiddleware", +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + +# Database +DATABASES = { + 'default': { + 'ENGINE': config('DB_ENGINE', 'django.db.backends.sqlite3'), + 'NAME': config('DB_NAME', BASE_DIR / 'db.sqlite3'), + 'USER': config('DB_USER'), + 'PASSWORD': config('DB_PASSWORD'), + 'HOST': config('DB_HOST', default='localhost'), + 'PORT': config('DB_PORT', default='5432'), + } +} + +# Password validation +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'Asia/Tehran' + +LANGUAGES = [ + ('en', 'English'), + ('fa', 'فارسی'), +] + +USE_I18N = True +USE_L10N = True +USE_TZ = True + +# For RTL support in admin +LOCALE_PATHS = [BASE_DIR / 'locale'] + +STATIC_URL = config('STATIC_URL', default='/static/') +STATIC_ROOT = BASE_DIR / 'staticfiles' +STATICFILES_DIRS = [BASE_DIR / 'static'] + +MEDIA_URL = config('MEDIA_URL', default='/media/') +MEDIA_ROOT = BASE_DIR / 'media' + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +AUTH_USER_MODEL = 'users.User' + +# CORS Settings +CORS_ALLOWED_ORIGINS = config('CORS_ALLOWED_ORIGINS', default='https://east-guilan-ce.ir').split(',') +CORS_ALLOW_CREDENTIALS = True +CSRF_TRUSTED_ORIGINS = ["https://east-guilan-ce.ir"] +CSRF_COOKIE_SECURE = True +SESSION_COOKIE_SECURE = True + +# Email Configuration +EMAIL_BACKEND = config('EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend') +EMAIL_HOST = config('EMAIL_HOST', default='') +EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int) +EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool) +EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='') +EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='') +DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='webmaster@localhost') + +# JWT Configuration +JWT_SECRET_KEY = config('JWT_SECRET_KEY', default=SECRET_KEY) +JWT_ALGORITHM = config('JWT_ALGORITHM', default='HS256') +JWT_ACCESS_TOKEN_LIFETIME = config('JWT_ACCESS_TOKEN_LIFETIME', default=3600, cast=int) +JWT_REFRESH_TOKEN_LIFETIME = config('JWT_REFRESH_TOKEN_LIFETIME', default=86400, cast=int) + +# Redis Configuration +REDIS_URL = config('REDIS_URL', default='redis://localhost:6379/0') + +# Cache Configuration +CACHES = { + 'default': { + 'BACKEND': 'django_prometheus.cache.backends.redis.RedisCache', + 'LOCATION': REDIS_URL, + } +} + +# Celery Configuration +CELERY_BROKER_URL = REDIS_URL +CELERY_RESULT_BACKEND = REDIS_URL + + +# Logging Configuration +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': { + 'verbose': { + 'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}', + 'style': '{', + }, + 'simple': { + 'format': '{levelname} {message}', + 'style': '{', + }, + }, + 'handlers': { + 'file': { + 'level': 'INFO', + 'class': 'logging.FileHandler', + 'filename': BASE_DIR / 'logs' / 'django.log', + 'formatter': 'verbose', + }, + 'console': { + 'level': 'INFO', + 'class': 'logging.StreamHandler', + 'formatter': 'simple', + }, + }, + 'root': { + 'handlers': ['console'], + 'level': 'INFO', + }, + 'loggers': { + 'django': { + 'handlers': ['file', 'console'], + 'level': 'INFO', + 'propagate': False, + }, + 'apps': { + 'handlers': ['file', 'console'], + 'level': 'INFO', + 'propagate': False, + }, + }, +} + +# Create logs directory +os.makedirs(BASE_DIR / 'logs', exist_ok=True) + +BACKEND_ROOT = config('DJANGO_HOST', default='http://localhost:8000/') +FRONTEND_ROOT = config('FRONTEND_ROOT', default='http://localhost:3000/') +FRONTEND_PASSWORD_RESET_PAGE = config('FRONTEND_PASSWORD_RESET_PAGE', default='http://localhost:3000/api/auth/reset-password-confirm/') +FRONTEND_CALLBACK_URL = config('FRONTEND_CALLBACK_URL', default='http://localhost:3000/payments/result') + +DATABASES["default"]["ENGINE"] = "django_prometheus.db.backends.postgresql" + +from config.services.unfold import * +from config.services.location import * +from config.services.notifications import * +from config.services.zarinpal import * diff --git a/backend/config/settings/development.py b/backend/config/settings/development.py new file mode 100644 index 0000000..eba78e6 --- /dev/null +++ b/backend/config/settings/development.py @@ -0,0 +1,18 @@ +from .base import * + +DEBUG = True + +# Additional development settings +INTERNAL_IPS = [ + "127.0.0.1", +] + +# Email backend for development +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' + +# Disable caching in development +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + } +} diff --git a/backend/config/settings/production.py b/backend/config/settings/production.py new file mode 100644 index 0000000..f0b9bb5 --- /dev/null +++ b/backend/config/settings/production.py @@ -0,0 +1,21 @@ +from .base import * + +DEBUG = False + +# Security settings for production +SECURE_BROWSER_XSS_FILTER = True +SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_HSTS_INCLUDE_SUBDOMAINS = True +SECURE_HSTS_SECONDS = 31536000 +SECURE_REDIRECT_EXEMPT = [] +SECURE_SSL_REDIRECT = True +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True +X_FRAME_OPTIONS = 'DENY' + +# 🔹 Exempt /metrics from the redirect so Prometheus can scrape over HTTP +SECURE_REDIRECT_EXEMPT = [r"^metrics$"] + +# Logging for production +# LOGGING['handlers']['file']['filename'] = '/var/log/django/django.log' diff --git a/backend/config/settings/test.py b/backend/config/settings/test.py new file mode 100644 index 0000000..11e528e --- /dev/null +++ b/backend/config/settings/test.py @@ -0,0 +1,46 @@ +from .base import * + +# Lightweight defaults keep local/CI test runs isolated from production infra. + +TEST_DB_ENGINE = config("TEST_DB_ENGINE", default="django.db.backends.sqlite3") +TEST_DB_NAME = config("TEST_DB_NAME", default=str(BASE_DIR / "db.test.sqlite3")) +TEST_DB_USER = config("TEST_DB_USER", default="") +TEST_DB_PASSWORD = config("TEST_DB_PASSWORD", default="") +TEST_DB_HOST = config("TEST_DB_HOST", default="") +TEST_DB_PORT = config("TEST_DB_PORT", default="") + +DATABASES["default"] = { + "ENGINE": TEST_DB_ENGINE, + "NAME": TEST_DB_NAME, + "USER": TEST_DB_USER, + "PASSWORD": TEST_DB_PASSWORD, + "HOST": TEST_DB_HOST, + "PORT": TEST_DB_PORT, +} + +PASSWORD_HASHERS = [ + "django.contrib.auth.hashers.MD5PasswordHasher", +] + +EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } +} + +CELERY_TASK_ALWAYS_EAGER = True +CELERY_TASK_EAGER_PROPAGATES = True + +# Tests should not enforce HTTPS-only cookies to simplify client simulations. +CSRF_COOKIE_SECURE = False +SESSION_COOKIE_SECURE = False + +# Silence verbose INFO logs (e.g., Celery task output) during tests. +LOGGING["handlers"]["console"]["level"] = "ERROR" # type: ignore[index] +LOGGING["root"]["level"] = "ERROR" # type: ignore[index] +if "django" in LOGGING["loggers"]: + LOGGING["loggers"]["django"]["level"] = "ERROR" # type: ignore[index] +if "apps" in LOGGING["loggers"]: + LOGGING["loggers"]["apps"]["level"] = "ERROR" # type: ignore[index] diff --git a/backend/config/urls.py b/backend/config/urls.py new file mode 100644 index 0000000..78e8e34 --- /dev/null +++ b/backend/config/urls.py @@ -0,0 +1,24 @@ +from django.contrib import admin +from django.urls import path, include +from django.conf import settings +from django.conf.urls.static import static +from ninja import NinjaAPI +from api.urls import router as api_router + +api = NinjaAPI( + title="CS Association API", + version="1.0.0", + description="API for University Computer Science Association", +) + +api.add_router("", api_router) + +urlpatterns = [ + path('admin/', admin.site.urls), + path('api/', api.urls), + path("", include("django_prometheus.urls")), +] + +if settings.DEBUG: + urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) + urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/backend/config/wsgi.py b/backend/config/wsgi.py new file mode 100644 index 0000000..85585bf --- /dev/null +++ b/backend/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') + +application = get_wsgi_application() diff --git a/backend/docker/entrypoint.sh b/backend/docker/entrypoint.sh new file mode 100644 index 0000000..c4015b4 --- /dev/null +++ b/backend/docker/entrypoint.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +: "${DJANGO_WSGI_MODULE:=config.wsgi:application}" +: "${DATABASE_URL:=postgres://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-app}}" + +# wait for db +host="db" +port="5432" +for i in {1..60}; do + if nc -z "$host" "$port"; then + echo "DB ready" + break + fi + echo "Waiting for DB... ($i)" + sleep 2 +done + +python manage.py migrate --noinput || true +python manage.py collectstatic --noinput || true + +# Start gunicorn (API) +( exec gunicorn "$DJANGO_WSGI_MODULE" --bind 0.0.0.0:8000 --workers ${GUNICORN_WORKERS:-3} --threads ${GUNICORN_THREADS:-2} --timeout 60 ) & + +# Start nginx (Frontend) +exec nginx -g "daemon off;" diff --git a/backend/docker/nginx.conf b/backend/docker/nginx.conf new file mode 100644 index 0000000..660fd49 --- /dev/null +++ b/backend/docker/nginx.conf @@ -0,0 +1,23 @@ +server { + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri/ /index.html; + } + + location /static/ { + alias /app/staticfiles/; + access_log off; + expires 30d; + } + + location /media/ { + alias /app/media/; + access_log off; + expires 30d; + } +} diff --git a/backend/events/admin.py b/backend/events/admin.py new file mode 100644 index 0000000..e1fe34f --- /dev/null +++ b/backend/events/admin.py @@ -0,0 +1,418 @@ +from django.contrib import admin, messages +from django.template.response import TemplateResponse +from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME +from django.template.loader import render_to_string +from django.conf import settings +from django.shortcuts import redirect +from django.urls import reverse_lazy + +from import_export.admin import ImportExportModelAdmin +from utils.templatetags.jalali import jdate +from unfold.decorators import action as unfold_action + +from utils.admin import SoftDeleteListFilter, BaseModelAdmin +from events.models import Event, Registration, EventEmailLog +from events.resources import EventResource, RegistrationResource +from events.tasks import ( + queue_skyroom_credentials, + send_skyroom_credentials_individual_task, + send_event_reminder_task, + queue_event_announcement, + queue_invites_to_non_registered_users, +) +from events.admin_forms import AnnouncementForm +from events.tasks import _send_html_email + + +@admin.register(Event) +class EventAdmin(BaseModelAdmin, ImportExportModelAdmin): + resource_class = EventResource + list_display = ( + 'title', 'event_type', 'start_time_display', 'end_time_display', 'status', + 'price_display', 'capacity_display', 'attendees_display', 'is_registration_open_display' + ) + list_filter = ( + 'event_type', 'status', 'is_deleted', + 'start_time', 'end_time', 'registration_start_date', 'registration_end_date', + SoftDeleteListFilter + ) + search_fields = ('title', 'description', 'address') + prepopulated_fields = {'slug': ('title',)} + date_hierarchy = 'start_time' + filter_horizontal = ('gallery_images',) + + fieldsets = ( + ('Event Details', { + 'fields': ('title', 'slug', 'description', 'featured_image') + }), + ('Timing & Type', { + 'fields': ('start_time', 'end_time', 'event_type', 'status') + }), + ('Location & Online', { + 'fields': ('address', 'location', 'online_link'), + 'description': 'For On-Site or Hybrid events, provide address and select on map. For Online events, provide a link.' + }), + ('Registration & Pricing', { + 'fields': ('capacity', 'price', 'registration_start_date', 'registration_end_date', 'registration_success_markdown'), + 'description': 'Leave capacity blank for unlimited. Leave price blank for free events.' + }), + ('Gallery', { + 'fields': ('gallery_images',), + 'description': 'Add images related to this event from the Gallery app.' + }), + ('Soft Delete', { + 'fields': ('is_deleted', 'deleted_at'), + 'classes': ('collapse',) + }), + ) + + readonly_fields = ('deleted_at',) + + actions = BaseModelAdmin.actions + [ + 'make_published', + 'make_draft', + 'make_cancelled', + 'make_completed', + 'restore_events', + ] + + actions_row = [ + 'action_send_announcement', + 'action_send_reminder_now', + 'action_send_skyroom_credentials', + 'action_invite_other_users', + ] + + @admin.display(description="Price") + def price_display(self, obj): + return obj.price if obj.price is not None else "رایگان" + + @admin.display(description="Start") + def start_time_display(self, obj): + return jdate(obj.start_time) + + @admin.display(description="End") + def end_time_display(self, obj): + return jdate(obj.end_time) + + @admin.display(description="Capacity") + def capacity_display(self, obj): + return obj.capacity if obj.capacity is not None else "نامحدود" + + @admin.display(description="Attendees") + def attendees_display(self, obj): + return obj.current_attendees_count + + @admin.display(description="Open", boolean=True) + def is_registration_open_display(self, obj): + return obj.is_registration_open + + @admin.action(description="Mark selected events as published") + def make_published(self, request, queryset): + queryset.update(status=Event.StatusChoices.PUBLISHED) + self.message_user(request, f"Published {queryset.count()} events.") + + @admin.action(description="Mark selected events as draft") + def make_draft(self, request, queryset): + queryset.update(status=Event.StatusChoices.DRAFT) + self.message_user(request, f"Marked {queryset.count()} events as draft.") + + @admin.action(description="Mark selected events as cancelled") + def make_cancelled(self, request, queryset): + queryset.update(status=Event.StatusChoices.CANCELLED) + self.message_user(request, f"Cancelled {queryset.count()} events.") + + @admin.action(description="Mark selected events as completed") + def make_completed(self, request, queryset): + queryset.update(status=Event.StatusChoices.COMPLETED) + self.message_user(request, f"Marked {queryset.count()} events as completed.") + + @admin.action(description="Restore selected events") + def restore_events(self, request, queryset): + for event in queryset: + event.restore() + self.message_user(request, f"Restored {queryset.count()} events.") + + @unfold_action(description="Send Skyroom Credentials") + def action_send_skyroom_credentials(self, request, object_id: int): + event = Event.objects.get(pk=object_id) + queue_skyroom_credentials.delay(event.pk) + self.message_user(request, f"ارسال مشخصات اسکای‌روم برای رویداد '{event.title}' صف شد.", messages.SUCCESS) + return redirect(reverse_lazy("admin:events_event_changelist")) + + @unfold_action(description="Send new Reminder") + def action_send_reminder_now(self, request, object_id: int): + event = Event.objects.get(pk=object_id) + send_event_reminder_task.delay(event.pk) + self.message_user(request, f"یادآوری برای رویداد '{event.title}' صف شد.", messages.SUCCESS) + return redirect(reverse_lazy("admin:events_event_changelist")) + + @unfold_action(description="send new Announcement") + def action_send_announcement(self, request, object_id: int): + """ + این اکشن یک فرم می‌گیرد (عنوان/متن/وضعیت‌ها) و با تمپلیت Unfold نشان داده می‌شود. + """ + form = AnnouncementForm(request.POST or None) + event = Event.objects.get(pk=object_id) + + if request.method == "POST" and form.is_valid(): + subject = form.cleaned_data["subject"] + body_html = form.cleaned_data["body_html"] + statuses = form.cleaned_data["statuses"] or None + queue_event_announcement.delay(event.pk, subject, body_html, statuses=statuses) + self.message_user(request, f"اطلاعیه برای رویداد '{event.title}' صف شد.", messages.SUCCESS) + return redirect(reverse_lazy("admin:events_event_changelist")) + + context = { + **self.admin_site.each_context(request), + "title": "ارسال اطلاعیه گروهی", + "opts": self.model._meta, + "form": form, + "action_name": "action_send_announcement", + "action_checkbox_name": ACTION_CHECKBOX_NAME, + } + return TemplateResponse(request, "forms/admin_announcement.html", context) + + @unfold_action(description="Invite other users") + def action_invite_other_users(self, request, object_id: int): + event = Event.objects.get(pk=object_id) + queue_invites_to_non_registered_users.delay(event.pk) + self.message_user(request, f"دعوت برای شرکت در رویداد '{event.title}' صف شد.", messages.SUCCESS) + return redirect(reverse_lazy("admin:events_event_changelist")) + + +@admin.register(Registration) +class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin): + resource_class = RegistrationResource + list_display = ( + 'user', + 'event', + 'status', + 'registered_at', + 'ticket_id', + 'discount_code', + 'discount_amount', + 'final_price', + ) + list_filter = ( + 'status', + 'event', + 'is_deleted', + 'registered_at', + SoftDeleteListFilter + ) + search_fields = ('user__username', 'user__email', 'user__first_name', 'user__last_name', 'event__title', 'ticket_id') + readonly_fields = ( + 'ticket_id', + 'registered_at', + 'confirmation_email_sent_at', + 'cancellation_email_sent_at', + 'discount_code', + 'discount_amount', + 'final_price', + 'deleted_at', + ) + + fieldsets = ( + ( + 'Registration Details', + { + 'fields': ( + 'user', + 'event', + 'status', + 'registered_at', + 'ticket_id', + 'confirmation_email_sent_at', + 'cancellation_email_sent_at', + ) + }, + ), + ( + 'Pricing & Discount', + { + 'fields': ('discount_code', 'discount_amount', 'final_price'), + 'classes': ('collapse',), + }, + ), + ('Soft Delete', { + 'fields': ('is_deleted', 'deleted_at'), + 'classes': ('collapse',) + }), + ) + + actions = BaseModelAdmin.actions + [ + 'confirm_registrations', + 'cancel_registrations', + 'mark_attended', + 'restore_registrations', + ] + actions_row = [ + 'action_email_selected', + 'action_send_skyroom_credentials', + ] + + @admin.action(description="Confirm selected registrations") + def confirm_registrations(self, request, queryset): + queryset.update(status=Registration.StatusChoices.CONFIRMED) + self.message_user(request, f"Confirmed {queryset.count()} registrations.") + + @admin.action(description="Cancel selected registrations") + def cancel_registrations(self, request, queryset): + queryset.update(status=Registration.StatusChoices.CANCELLED) + self.message_user(request, f"Cancelled {queryset.count()} registrations.") + + @admin.action(description="Mark selected registrations as attended") + def mark_attended(self, request, queryset): + queryset.update(status=Registration.StatusChoices.ATTENDED) + self.message_user(request, f"Marked {queryset.count()} registrations as attended.") + + @admin.action(description="Restore selected registrations") + def restore_registrations(self, request, queryset): + for registration in queryset: + registration.restore() + self.message_user(request, f"Restored {queryset.count()} registrations.") + + @unfold_action(description="send email to registrated user") + def action_email_selected(self, request, object_id: int): + """ + همان فرم اطلاعیه را می‌گیرد و به افراد انتخاب‌شده ایمیل می‌زند. + برای نمایش فرم، از تمپلیت Unfold استفاده می‌کنیم. + """ + form = AnnouncementForm(request.POST or None) + registration = Registration.objects.get(id=object_id) + + if request.method == "POST" and form.is_valid(): + subject = form.cleaned_data["subject"] + body_html = form.cleaned_data["body_html"] + + user = registration.user + ctx = { + "user": user, + "event": registration.event, + "body_html": body_html, + "event_url": f"{settings.FRONTEND_ROOT}events/{registration.event.slug}", + } + html = render_to_string("emails/event_announcement.html", ctx) + _send_html_email(subject, html, user.email) + + self.message_user(request, f"ارسال ایمیل انجام شد.", messages.SUCCESS) + return redirect(reverse_lazy("admin:events_registration_changelist")) + + context = { + **self.admin_site.each_context(request), + "title": "ارسال ایمیل به ثبت‌نام‌های انتخاب‌شده", + "form": AnnouncementForm(), + "opts": self.model._meta, + "action_name": "action_email_selected", + "action_checkbox_name": ACTION_CHECKBOX_NAME, + } + return TemplateResponse(request, "forms/admin_announcement.html", context) + + @unfold_action(description="Send Skyroom Credentials") + def action_send_skyroom_credentials(self, request, object_id: int): + send_skyroom_credentials_individual_task.delay(object_id) + self.message_user(request, f"ارسال مشخصات اسکای‌روم به کاربر مربوطه صف شد.", messages.SUCCESS) + return redirect(reverse_lazy("admin:events_registration_changelist")) + + +from events.tasks import send_invite_to_user + + + +@admin.register(EventEmailLog) +class EventEmailLogAdmin(BaseModelAdmin, ImportExportModelAdmin): + list_display = ( + "id", + "event", + "user", + "user_email", + "kind", + "status", + "sent_at", + "created_at", + ) + list_filter = ( + "kind", + "status", + "event", + ("sent_at", admin.EmptyFieldListFilter), + ("error", admin.EmptyFieldListFilter), + SoftDeleteListFilter, + ) + search_fields = ( + "user__email", + "user__username", + "user__first_name", + "user__last_name", + "event__title", + ) + autocomplete_fields = ("event", "user") + date_hierarchy = "created_at" + ordering = ("-created_at",) + list_per_page = 50 + list_select_related = ("event", "user") + + # چون این مدل برای ایدمپوتنسی حیاتی است، ویرایش دستی را محدود می‌کنیم + readonly_fields = ( + "event", + "user", + "kind", + "status", + "error", + "sent_at", + "created_at", + "updated_at", + ) + fields = readonly_fields + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return True + + actions = BaseModelAdmin.actions + [ + 'resend_selected_emails' + ] + + @admin.display(description="Email", ordering="user__email") + def user_email(self, obj): + return obj.user.email or "—" + + @admin.action(description="ارسال مجدد ایمیل برای رکوردهای انتخاب‌شده") + def resend_selected_emails(self, request, queryset): + """ + رکوردهای SENT را اسکیپ می‌کند، بقیه را به وضعیت pending برمی‌گرداند + و تسک ارسال تکی را در صف می‌گذارد (ایدِمپوتنت). + """ + queued = 0 + skipped = 0 + + for log in queryset.select_related("event", "user"): + if log.status == EventEmailLog.STATUS_SENT: + skipped += 1 + continue + + # برگرداندن به pending و پاک کردن خطا + if log.status != EventEmailLog.STATUS_PENDING or log.error: + log.status = EventEmailLog.STATUS_PENDING + log.error = "" + log.save(update_fields=["status", "error", "updated_at"]) + + # صف کردن تسک اتمی + send_invite_to_user.delay(log.event_id, log.user_id) + queued += 1 + + if queued: + self.message_user( + request, + "%(n)d مورد در صف ارسال قرار گرفت." % {"n": queued}, + level=messages.SUCCESS, + ) + if skipped: + self.message_user( + request, + "%(n)d مورد قبلاً ارسال شده بود و نادیده گرفته شد." % {"n": skipped}, + level=messages.WARNING, + ) diff --git a/backend/events/admin_forms.py b/backend/events/admin_forms.py new file mode 100644 index 0000000..52ddab5 --- /dev/null +++ b/backend/events/admin_forms.py @@ -0,0 +1,25 @@ +from django import forms + +from unfold.widgets import UnfoldAdminTextInputWidget, UnfoldAdminTextareaWidget + +from events.models import Registration + + +class AnnouncementForm(forms.Form): + subject = forms.CharField( + label="Subject", + max_length=200, + widget=UnfoldAdminTextInputWidget, + ) + body_html = forms.CharField( + label="Text (HTML or plain-text)", + widget=UnfoldAdminTextareaWidget, + help_text="you can enter either HTML or plain-text." + ) + statuses = forms.MultipleChoiceField( + label="Statuses to sent", + required=False, + choices=Registration.StatusChoices.choices, + initial=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED], + widget=forms.CheckboxSelectMultiple, + ) diff --git a/backend/events/apps.py b/backend/events/apps.py new file mode 100644 index 0000000..20f48f2 --- /dev/null +++ b/backend/events/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EventsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'events' diff --git a/backend/events/fixtures/events.json b/backend/events/fixtures/events.json new file mode 100644 index 0000000..cbd39a1 --- /dev/null +++ b/backend/events/fixtures/events.json @@ -0,0 +1,379 @@ +[ + { + "model": "events.event", + "pk": 1, + "fields": { + "created_at": "2024-02-28T10:00:00Z", + "updated_at": "2024-02-28T10:00:00Z", + "is_deleted": false, + "title": "کارگاه یادگیری ماشین پیشرفته", + "slug": "advanced-machine-learning-workshop", + "description": "# کارگاه یادگیری ماشین پیشرفته\n\nدر این کارگاه با تکنیک‌های پیشرفته یادگیری ماشین آشنا خواهید شد.\n\n## سرفصل‌ها:\n- Deep Learning\n- Neural Networks\n- TensorFlow و Keras\n- پروژه عملی\n\n## پیش‌نیازها:\n- آشنایی با پایتون\n- دانش پایه ریاضی\n- تجربه کار با NumPy", + "start_time": "2024-03-15T14:00:00Z", + "end_time": "2024-03-15T18:00:00Z", + "event_type": "on_site", + "address": "سالن کنفرانس دانشکده مهندسی کامپیوتر", + "location": "35.7219,51.3890", + "status": "published", + "capacity": 50, + "price": "150000.00", + "registration_start_date": "2024-03-01T00:00:00Z", + "registration_end_date": "2024-03-14T23:59:59Z" + } + }, + { + "model": "events.event", + "pk": 2, + "fields": { + "created_at": "2024-03-02T09:00:00Z", + "updated_at": "2024-03-02T09:00:00Z", + "is_deleted": false, + "title": "مسابقه برنامه‌نویسی بهاری", + "slug": "spring-programming-contest", + "description": "# مسابقه برنامه‌نویسی بهاری\n\nمسابقه‌ای هیجان‌انگیز برای تمامی علاقه‌مندان به برنامه‌نویسی\n\n## جوایز:\n- نفر اول: ۵ میلیون تومان\n- نفر دوم: ۳ میلیون تومان \n- نفر سوم: ۲ میلیون تومان\n\n## قوانین:\n- مسابقه انفرادی\n- مدت زمان: ۳ ساعت\n- ۸ مسئله الگوریتمی\n- زبان‌های مجاز: C++, Java, Python", + "start_time": "2024-03-22T09:00:00Z", + "end_time": "2024-03-22T12:00:00Z", + "event_type": "on_site", + "address": "آزمایشگاه کامپیوتر شماره ۱", + "location": "35.7225,51.3885", + "status": "published", + "capacity": 80, + "price": null, + "registration_start_date": "2024-03-05T00:00:00Z", + "registration_end_date": "2024-03-20T23:59:59Z" + } + }, + { + "model": "events.event", + "pk": 3, + "fields": { + "created_at": "2024-03-08T11:00:00Z", + "updated_at": "2024-03-08T11:00:00Z", + "is_deleted": false, + "title": "وبینار امنیت سایبری", + "slug": "cybersecurity-webinar", + "description": "# وبینار امنیت سایبری\n\nآشنایی با آخرین تهدیدات سایبری و روش‌های مقابله\n\n## موضوعات:\n- تهدیدات جدید سایبری\n- روش‌های حفاظت\n- ابزارهای امنیتی\n- مطالعه موردی حملات\n\n## مدرس:\nدکتر محمد رضایی - متخصص امنیت سایبری", + "start_time": "2024-03-28T19:00:00Z", + "end_time": "2024-03-28T21:00:00Z", + "event_type": "online", + "online_link": "https://meet.google.com/abc-defg-hij", + "status": "published", + "capacity": 200, + "price": null, + "registration_start_date": "2024-03-10T00:00:00Z", + "registration_end_date": "2024-03-27T23:59:59Z" + } + }, + { + "model": "events.event", + "pk": 4, + "fields": { + "created_at": "2024-03-18T14:00:00Z", + "updated_at": "2024-03-18T14:00:00Z", + "is_deleted": false, + "title": "کارگاه React.js و Next.js", + "slug": "reactjs-nextjs-workshop", + "description": "# کارگاه React.js و Next.js\n\nآموزش کامل توسعه وب مدرن با React و Next.js\n\n## محتوای کارگاه:\n- مبانی React.js\n- Hooks و State Management\n- Next.js و SSR\n- پروژه عملی\n\n## مدرس:\nمهندس امیر قربانی - توسعه‌دهنده فول‌استک", + "start_time": "2024-04-05T13:00:00Z", + "end_time": "2024-04-05T17:00:00Z", + "event_type": "hybrid", + "address": "کلاس ۲۰۵ ساختمان مهندسی کامپیوتر", + "location": "35.7230,51.3880", + "online_link": "https://zoom.us/j/123456789", + "status": "published", + "capacity": 40, + "price": "200000.00", + "registration_start_date": "2024-03-20T00:00:00Z", + "registration_end_date": "2024-04-04T23:59:59Z" + } + }, + { + "model": "events.event", + "pk": 5, + "fields": { + "created_at": "2024-03-22T16:00:00Z", + "updated_at": "2024-03-22T16:00:00Z", + "is_deleted": false, + "title": "بازدید از شرکت دیجی‌کالا", + "slug": "digikala-company-visit", + "description": "# بازدید از شرکت دیجی‌کالا\n\nبازدید علمی از یکی از بزرگ‌ترین شرکت‌های فناوری کشور\n\n## برنامه بازدید:\n- آشنایی با ساختار شرکت\n- بازدید از بخش‌های مختلف\n- گفتگو با مهندسان\n- معرفی فرصت‌های شغلی\n\n## نکات مهم:\n- حمل و نقل رایگان\n- ناهار در محل\n- اهدای هدایای تبلیغاتی", + "start_time": "2024-04-12T08:00:00Z", + "end_time": "2024-04-12T16:00:00Z", + "event_type": "on_site", + "address": "شرکت دیجی‌کالا، تهران", + "location": "35.7580,51.4100", + "status": "published", + "capacity": 30, + "price": null, + "registration_start_date": "2024-03-25T00:00:00Z", + "registration_end_date": "2024-04-10T23:59:59Z" + } + }, + { + "model": "events.event", + "pk": 6, + "fields": { + "created_at": "2024-03-30T12:00:00Z", + "updated_at": "2024-03-30T12:00:00Z", + "is_deleted": false, + "title": "هکاتون هوش مصنوعی", + "slug": "ai-hackathon", + "description": "# هکاتون هوش مصنوعی\n\nرقابت ۴۸ ساعته برای ساخت پروژه‌های هوش مصنوعی\n\n## موضوعات:\n- پردازش زبان طبیعی\n- بینایی کامپیوتر\n- یادگیری تقویتی\n- هوش مصنوعی در پزشکی\n\n## جوایز:\n- تیم اول: ۱۰ میلیون تومان\n- تیم دوم: ۶ میلیون تومان\n- تیم سوم: ۴ میلیون تومان\n\n## امکانات:\n- غذا و نوشیدنی رایگان\n- فضای کار ۲۴ ساعته\n- منتورینگ توسط اساتید", + "start_time": "2024-04-19T18:00:00Z", + "end_time": "2024-04-21T18:00:00Z", + "event_type": "on_site", + "address": "مرکز نوآوری دانشگاه", + "location": "35.7200,51.3900", + "status": "published", + "capacity": 60, + "price": "100000.00", + "registration_start_date": "2024-04-01T00:00:00Z", + "registration_end_date": "2024-04-17T23:59:59Z" + } + }, + { + "model": "events.event", + "pk": 7, + "fields": { + "created_at": "2024-04-08T15:00:00Z", + "updated_at": "2024-04-08T15:00:00Z", + "is_deleted": false, + "title": "سمینار کارآفرینی فناوری", + "slug": "tech-entrepreneurship-seminar", + "description": "# سمینار کارآفرینی فناوری\n\nآشنایی با دنیای کارآفرینی و استارتاپ‌های فناوری\n\n## سخنرانان:\n- دکتر علی احمدی - موسس استارتاپ تپسی\n- خانم سارا محمدی - مدیرعامل کافه‌بازار\n- مهندس رضا کریمی - سرمایه‌گذار فرشته\n\n## موضوعات:\n- ایده‌یابی و اعتبارسنجی\n- تیم‌سازی\n- جذب سرمایه\n- بازاریابی دیجیتال", + "start_time": "2024-04-26T14:00:00Z", + "end_time": "2024-04-26T18:00:00Z", + "event_type": "hybrid", + "address": "آمفی‌تئاتر مرکزی دانشگاه", + "location": "35.7210,51.3895", + "online_link": "https://meet.google.com/xyz-uvw-rst", + "status": "published", + "capacity": 150, + "price": null, + "registration_start_date": "2024-04-10T00:00:00Z", + "registration_end_date": "2024-04-25T23:59:59Z" + } + }, + { + "model": "events.event", + "pk": 8, + "fields": { + "created_at": "2024-04-12T13:00:00Z", + "updated_at": "2024-04-12T13:00:00Z", + "is_deleted": false, + "title": "کارگاه DevOps و Docker", + "slug": "devops-docker-workshop", + "description": "# کارگاه DevOps و Docker\n\nآموزش عملی ابزارهای DevOps و کانتینریزیشن\n\n## سرفصل‌ها:\n- مقدمه‌ای بر DevOps\n- Docker و Containerization\n- Docker Compose\n- CI/CD Pipeline\n- Kubernetes مقدماتی\n\n## پیش‌نیازها:\n- آشنایی با Linux\n- تجربه کار با Terminal\n- دانش پایه شبکه", + "start_time": "2024-05-03T09:00:00Z", + "end_time": "2024-05-03T17:00:00Z", + "event_type": "on_site", + "address": "آزمایشگاه شبکه دانشکده", + "location": "35.7215,51.3888", + "status": "published", + "capacity": 25, + "price": "300000.00", + "registration_start_date": "2024-04-15T00:00:00Z", + "registration_end_date": "2024-05-01T23:59:59Z" + } + }, + { + "model": "events.event", + "pk": 9, + "fields": { + "created_at": "2024-04-18T10:00:00Z", + "updated_at": "2024-04-18T10:00:00Z", + "is_deleted": false, + "title": "مسابقه طراحی UI/UX", + "slug": "ui-ux-design-contest", + "description": "# مسابقه طراحی UI/UX\n\nرقابت خلاقانه برای طراحی بهترین رابط کاربری\n\n## موضوع مسابقه:\nطراحی اپلیکیشن موبایل برای مدیریت تسک‌های دانشجویی\n\n## معیارهای داوری:\n- خلاقیت و نوآوری\n- قابلیت استفاده\n- زیبایی بصری\n- تجربه کاربری\n\n## جوایز:\n- نفر اول: تبلت iPad\n- نفر دوم: هدفون بی‌سیم\n- نفر سوم: پاوربانک", + "start_time": "2024-05-10T10:00:00Z", + "end_time": "2024-05-10T18:00:00Z", + "event_type": "on_site", + "address": "استودیو طراحی دانشکده هنر", + "location": "35.7240,51.3870", + "status": "published", + "capacity": 40, + "price": "50000.00", + "registration_start_date": "2024-04-20T00:00:00Z", + "registration_end_date": "2024-05-08T23:59:59Z" + } + }, + { + "model": "events.event", + "pk": 10, + "fields": { + "created_at": "2024-04-28T17:00:00Z", + "updated_at": "2024-04-28T17:00:00Z", + "is_deleted": false, + "title": "نشست فارغ‌التحصیلان", + "slug": "alumni-meetup", + "description": "# نشست فارغ‌التحصیلان\n\nدیدار با فارغ‌التحصیلان موفق رشته مهندسی کامپیوتر\n\n## برنامه:\n- معرفی فارغ‌التحصیلان\n- تجربیات شغلی\n- مشاوره تحصیلی\n- شبکه‌سازی\n- ضیافت شام\n\n## مهمانان ویژه:\n- دکتر حسن زارع - مدیر فنی گوگل\n- مهندس مریم حسینی - بنیان‌گذار استارتاپ\n- دکتر امیر قربانی - استاد MIT", + "start_time": "2024-05-17T17:00:00Z", + "end_time": "2024-05-17T22:00:00Z", + "event_type": "on_site", + "address": "سالن همایش‌های دانشگاه", + "location": "35.7205,51.3892", + "status": "published", + "capacity": 100, + "price": null, + "registration_start_date": "2024-05-01T00:00:00Z", + "registration_end_date": "2024-05-15T23:59:59Z" + } + }, + { + "model": "events.registration", + "pk": 1, + "fields": { + "created_at": "2024-03-02T10:30:00Z", + "updated_at": "2024-03-02T10:30:00Z", + "is_deleted": false, + "registered_at": "2024-03-02T10:30:00Z", + "event": 1, + "user": 3, + "status": "confirmed" + } + }, + { + "model": "events.registration", + "pk": 2, + "fields": { + "created_at": "2024-03-03T14:15:00Z", + "updated_at": "2024-03-03T14:15:00Z", + "is_deleted": false, + "registered_at": "2024-03-03T14:15:00Z", + "event": 1, + "user": 4, + "status": "confirmed" + } + }, + { + "model": "events.registration", + "pk": 3, + "fields": { + "created_at": "2024-03-06T09:20:00Z", + "updated_at": "2024-03-06T09:20:00Z", + "is_deleted": false, + "registered_at": "2024-03-06T09:20:00Z", + "event": 2, + "user": 5, + "status": "confirmed" + } + }, + { + "model": "events.registration", + "pk": 4, + "fields": { + "created_at": "2024-03-07T16:45:00Z", + "updated_at": "2024-03-07T16:45:00Z", + "is_deleted": false, + "registered_at": "2024-03-07T16:45:00Z", + "event": 2, + "user": 6, + "status": "confirmed" + } + }, + { + "model": "events.registration", + "pk": 5, + "fields": { + "created_at": "2024-03-12T11:30:00Z", + "updated_at": "2024-03-12T11:30:00Z", + "is_deleted": false, + "registered_at": "2024-03-12T11:30:00Z", + "event": 3, + "user": 7, + "status": "confirmed" + } + }, + { + "model": "events.registration", + "pk": 6, + "fields": { + "created_at": "2024-03-13T13:25:00Z", + "updated_at": "2024-03-13T13:25:00Z", + "is_deleted": false, + "registered_at": "2024-03-13T13:25:00Z", + "event": 3, + "user": 8, + "status": "confirmed" + } + }, + { + "model": "events.registration", + "pk": 7, + "fields": { + "created_at": "2024-03-22T15:10:00Z", + "updated_at": "2024-03-22T15:10:00Z", + "is_deleted": false, + "registered_at": "2024-03-22T15:10:00Z", + "event": 4, + "user": 9, + "status": "pending" + } + }, + { + "model": "events.registration", + "pk": 8, + "fields": { + "created_at": "2024-03-23T12:40:00Z", + "updated_at": "2024-03-23T12:40:00Z", + "is_deleted": false, + "registered_at": "2024-03-23T12:40:00Z", + "event": 4, + "user": 10, + "status": "confirmed" + } + }, + { + "model": "events.registration", + "pk": 9, + "fields": { + "created_at": "2024-03-27T08:55:00Z", + "updated_at": "2024-03-27T08:55:00Z", + "is_deleted": false, + "registered_at": "2024-03-27T08:55:00Z", + "event": 5, + "user": 11, + "status": "confirmed" + } + }, + { + "model": "events.registration", + "pk": 10, + "fields": { + "created_at": "2024-04-02T14:20:00Z", + "updated_at": "2024-04-02T14:20:00Z", + "is_deleted": false, + "registered_at": "2024-04-02T14:20:00Z", + "event": 6, + "user": 12, + "status": "confirmed" + } + }, + { + "model": "events.registration", + "pk": 11, + "fields": { + "created_at": "2024-04-12T10:15:00Z", + "updated_at": "2024-04-12T10:15:00Z", + "is_deleted": false, + "registered_at": "2024-04-12T10:15:00Z", + "event": 7, + "user": 2, + "status": "confirmed" + } + }, + { + "model": "events.registration", + "pk": 12, + "fields": { + "created_at": "2024-04-16T16:30:00Z", + "updated_at": "2024-04-16T16:30:00Z", + "is_deleted": false, + "registered_at": "2024-04-16T16:30:00Z", + "event": 8, + "user": 1, + "status": "confirmed" + } + } +] diff --git a/backend/events/migrations/0001_initial.py b/backend/events/migrations/0001_initial.py new file mode 100644 index 0000000..76afc34 --- /dev/null +++ b/backend/events/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.2.5 on 2025-10-16 12:07 + +import location_field.models.plain +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Event', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('title', models.CharField(max_length=255)), + ('slug', models.SlugField(blank=True, max_length=255, unique=True)), + ('description', models.TextField(help_text='Event description in Markdown format')), + ('start_time', models.DateTimeField()), + ('end_time', models.DateTimeField()), + ('address', models.CharField(blank=True, help_text='Physical address or venue name', max_length=255, null=True)), + ('location', location_field.models.plain.PlainLocationField(blank=True, help_text='Select location on map', max_length=63, null=True)), + ('event_type', models.CharField(choices=[('online', 'آنلاین'), ('on_site', 'حضوری'), ('hybrid', 'آنلاین/حضوری')], default='on_site', max_length=10)), + ('online_link', models.URLField(blank=True, help_text='Link for online events (e.g., Zoom, Google Meet)', max_length=500, null=True)), + ('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], default='draft', max_length=10)), + ('capacity', models.PositiveIntegerField(blank=True, help_text='Maximum number of attendees (leave blank for unlimited)', null=True)), + ('price', models.IntegerField(default=0, help_text='Price of the event. Leave blank for free events.')), + ('registration_start_date', models.DateTimeField(blank=True, null=True)), + ('registration_end_date', models.DateTimeField(blank=True, null=True)), + ('featured_image', models.ImageField(blank=True, null=True, upload_to='events/featured/')), + ], + options={ + 'ordering': ['start_time'], + }, + ), + migrations.CreateModel( + name='Registration', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('registered_at', models.DateTimeField(auto_now_add=True)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('attended', 'Attended')], default='pending', max_length=10)), + ('ticket_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ], + options={ + 'ordering': ['registered_at'], + }, + ), + ] diff --git a/backend/events/migrations/0002_initial.py b/backend/events/migrations/0002_initial.py new file mode 100644 index 0000000..3872487 --- /dev/null +++ b/backend/events/migrations/0002_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.5 on 2025-10-16 12:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('events', '0001_initial'), + ('gallery', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='gallery_images', + field=models.ManyToManyField(blank=True, help_text='Images taken during or related to the event.', related_name='event_galleries', to='gallery.gallery'), + ), + migrations.AddField( + model_name='registration', + name='event', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registrations', to='events.event'), + ), + ] diff --git a/backend/events/migrations/0003_initial.py b/backend/events/migrations/0003_initial.py new file mode 100644 index 0000000..60e4b53 --- /dev/null +++ b/backend/events/migrations/0003_initial.py @@ -0,0 +1,39 @@ +# Generated by Django 5.2.5 on 2025-10-16 12:07 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('events', '0002_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='registration', + name='user', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_registrations', to=settings.AUTH_USER_MODEL), + ), + migrations.AddIndex( + model_name='event', + index=models.Index(fields=['status', 'start_time'], name='events_even_status_189ced_idx'), + ), + migrations.AddIndex( + model_name='event', + index=models.Index(fields=['event_type'], name='events_even_event_t_a87b5c_idx'), + ), + migrations.AddIndex( + model_name='registration', + index=models.Index(fields=['event', 'status'], name='events_regi_event_i_c98244_idx'), + ), + migrations.AddIndex( + model_name='registration', + index=models.Index(fields=['user'], name='events_regi_user_id_a0262e_idx'), + ), + ] diff --git a/backend/events/migrations/0004_event_registration_success_markdown.py b/backend/events/migrations/0004_event_registration_success_markdown.py new file mode 100644 index 0000000..abf179c --- /dev/null +++ b/backend/events/migrations/0004_event_registration_success_markdown.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-10-16 12:09 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0003_initial'), + ] + + operations = [ + migrations.AddField( + model_name='event', + name='registration_success_markdown', + field=models.TextField(blank=True, help_text='Optional markdown shown to users after a successful registration.', null=True), + ), + ] diff --git a/backend/events/migrations/0005_registration_cancellation_email_sent_at_and_more.py b/backend/events/migrations/0005_registration_cancellation_email_sent_at_and_more.py new file mode 100644 index 0000000..aac9926 --- /dev/null +++ b/backend/events/migrations/0005_registration_cancellation_email_sent_at_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.5 on 2025-10-16 13:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0004_event_registration_success_markdown'), + ] + + operations = [ + migrations.AddField( + model_name='registration', + name='cancellation_email_sent_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='registration', + name='confirmation_email_sent_at', + field=models.DateTimeField(blank=True, null=True), + ), + ] diff --git a/backend/events/migrations/0006_alter_event_options_alter_registration_options_and_more.py b/backend/events/migrations/0006_alter_event_options_alter_registration_options_and_more.py new file mode 100644 index 0000000..66a3a0f --- /dev/null +++ b/backend/events/migrations/0006_alter_event_options_alter_registration_options_and_more.py @@ -0,0 +1,43 @@ +# Generated by Django 5.2.5 on 2025-10-25 20:47 + +import django.db.models.deletion +import django.utils.timezone +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0005_registration_cancellation_email_sent_at_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='event', + options={'ordering': ['-start_time']}, + ), + migrations.AlterModelOptions( + name='registration', + options={'ordering': ['-registered_at']}, + ), + migrations.CreateModel( + name='EventEmailLog', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('kind', models.CharField(choices=[('invite_non_registered', 'Invite non-registered users')], max_length=64)), + ('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('failed', 'Failed')], default='pending', max_length=16)), + ('error', models.TextField(blank=True, null=True)), + ('sent_at', models.DateTimeField(blank=True, null=True)), + ('created_at', models.DateTimeField(default=django.utils.timezone.now)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_logs', to='events.event')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_logs', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'indexes': [models.Index(fields=['event', 'kind', 'status'], name='events_even_event_i_d6c2f2_idx'), models.Index(fields=['user', 'kind', 'status'], name='events_even_user_id_67be40_idx')], + 'unique_together': {('event', 'user', 'kind')}, + }, + ), + ] diff --git a/backend/events/migrations/0007_eventemaillog_deleted_at_eventemaillog_is_deleted_and_more.py b/backend/events/migrations/0007_eventemaillog_deleted_at_eventemaillog_is_deleted_and_more.py new file mode 100644 index 0000000..f783492 --- /dev/null +++ b/backend/events/migrations/0007_eventemaillog_deleted_at_eventemaillog_is_deleted_and_more.py @@ -0,0 +1,28 @@ +# Generated by Django 5.2.5 on 2025-10-25 21:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0006_alter_event_options_alter_registration_options_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='eventemaillog', + name='deleted_at', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='eventemaillog', + name='is_deleted', + field=models.BooleanField(default=False), + ), + migrations.AlterField( + model_name='eventemaillog', + name='created_at', + field=models.DateTimeField(auto_now_add=True), + ), + ] diff --git a/backend/events/migrations/0008_alter_eventemaillog_kind.py b/backend/events/migrations/0008_alter_eventemaillog_kind.py new file mode 100644 index 0000000..16dbab2 --- /dev/null +++ b/backend/events/migrations/0008_alter_eventemaillog_kind.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-11-05 11:29 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0007_eventemaillog_deleted_at_eventemaillog_is_deleted_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='eventemaillog', + name='kind', + field=models.CharField(choices=[('invite_non_registered', 'Invite non-registered users'), ('send_skyroom_credentials', 'send skyroom credentials'), ('send_event_announcement', 'send_event_announcement'), ('send_event_announcement2', 'send_event_announcement2'), ('send_event_announcement3', 'send_event_announcement3')], max_length=64), + ), + ] diff --git a/backend/events/migrations/0009_registration_discount_amount_and_more.py b/backend/events/migrations/0009_registration_discount_amount_and_more.py new file mode 100644 index 0000000..7c3d893 --- /dev/null +++ b/backend/events/migrations/0009_registration_discount_amount_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.13 on 2025-11-17 13:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('payments', '0002_initial'), + ('events', '0008_alter_eventemaillog_kind'), + ] + + operations = [ + migrations.AddField( + model_name='registration', + name='discount_amount', + field=models.PositiveIntegerField(default=0), + ), + migrations.AddField( + model_name='registration', + name='discount_code', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='registrations', to='payments.discountcode'), + ), + migrations.AddField( + model_name='registration', + name='final_price', + field=models.PositiveIntegerField(blank=True, null=True), + ), + ] diff --git a/backend/events/migrations/0010_backfill_registration_discounts.py b/backend/events/migrations/0010_backfill_registration_discounts.py new file mode 100644 index 0000000..41e550d --- /dev/null +++ b/backend/events/migrations/0010_backfill_registration_discounts.py @@ -0,0 +1,55 @@ +from django.db import migrations + + +def copy_payment_discounts(apps, schema_editor): + Registration = apps.get_model("events", "Registration") + Payment = apps.get_model("payments", "Payment") + + payments = ( + Payment.objects.exclude(discount_code__isnull=True) + .select_related("discount_code") + .order_by("id") + ) + for payment in payments: + registration = ( + Registration.objects.filter(event_id=payment.event_id, user_id=payment.user_id) + .order_by("-registered_at") + .first() + ) + if not registration: + continue + + updated_fields = [] + if payment.discount_code_id and not registration.discount_code_id: + registration.discount_code_id = payment.discount_code_id + updated_fields.append("discount_code") + if payment.discount_amount and not registration.discount_amount: + registration.discount_amount = payment.discount_amount + updated_fields.append("discount_amount") + if payment.amount is not None and registration.final_price is None: + registration.final_price = payment.amount + updated_fields.append("final_price") + + if updated_fields: + registration.save(update_fields=updated_fields) + + if payment.registration_id is None: + payment.registration_id = registration.id + payment.save(update_fields=["registration"]) + + +def reverse_copy_payment_discounts(apps, schema_editor): + # No-op for reverse; data retention preferred. + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("payments", "0003_payment_registration"), + ("events", "0009_registration_discount_amount_and_more"), + ] + + operations = [ + migrations.RunPython(copy_payment_discounts, reverse_copy_payment_discounts), + ] diff --git a/backend/events/migrations/0011_eventemaillog_context_hash.py b/backend/events/migrations/0011_eventemaillog_context_hash.py new file mode 100644 index 0000000..3597e9a --- /dev/null +++ b/backend/events/migrations/0011_eventemaillog_context_hash.py @@ -0,0 +1,22 @@ +# Generated by Django 5.2.5 on 2025-11-17 19:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0010_backfill_registration_discounts'), + ] + + operations = [ + migrations.AddField( + model_name='eventemaillog', + name='context_hash', + field=models.CharField(blank=True, max_length=64, null=True), + ), + migrations.AlterUniqueTogether( + name='eventemaillog', + unique_together={('event', 'user', 'kind', 'context_hash')}, + ), + ] diff --git a/backend/events/migrations/0012_alter_eventemaillog_kind.py b/backend/events/migrations/0012_alter_eventemaillog_kind.py new file mode 100644 index 0000000..622b25c --- /dev/null +++ b/backend/events/migrations/0012_alter_eventemaillog_kind.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.13 on 2025-11-18 08:54 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0011_eventemaillog_context_hash'), + ] + + operations = [ + migrations.AlterField( + model_name='eventemaillog', + name='kind', + field=models.CharField(choices=[('invite_non_registered', 'Invite non-registered users'), ('send_skyroom_credentials', 'Skyroom credentials'), ('send_event_announcement', 'Event announcement'), ('send_event_announcement2', 'Event announcement 2'), ('send_event_announcement3', 'Event announcement 3')], max_length=64), + ), + ] diff --git a/backend/events/migrations/__init__.py b/backend/events/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/events/models.py b/backend/events/models.py new file mode 100644 index 0000000..44d557c --- /dev/null +++ b/backend/events/models.py @@ -0,0 +1,269 @@ +from django.db import models +from django.db import models +from django.conf import settings +from django.utils import timezone +from django.utils.text import slugify + +import hashlib +import uuid + +import markdown +from location_field.models.plain import PlainLocationField as LocationField + +from utils.models import BaseModel + + +class Event(BaseModel): + class TypeChoices(models.TextChoices): + ONLINE = 'online', 'آنلاین' + ON_SITE = 'on_site', 'حضوری' + HYBRID = 'hybrid', 'آنلاین/حضوری' + + class StatusChoices(models.TextChoices): + DRAFT = 'draft', 'Draft' + PUBLISHED = 'published', 'Published' + CANCELLED = 'cancelled', 'Cancelled' + COMPLETED = 'completed', 'Completed' + + title = models.CharField(max_length=255) + slug = models.SlugField(max_length=255, unique=True, blank=True) + description = models.TextField(help_text="Event description in Markdown format") + + start_time = models.DateTimeField() + end_time = models.DateTimeField() + + address = models.CharField(max_length=255, blank=True, null=True, help_text="Physical address or venue name") + location = LocationField(based_fields=['address'], zoom=15, blank=True, null=True, + help_text="Select location on map") + + event_type = models.CharField(max_length=10, choices=TypeChoices.choices, default=TypeChoices.ON_SITE) + online_link = models.URLField(max_length=500, blank=True, null=True, + help_text="Link for online events (e.g., Zoom, Google Meet)") + + status = models.CharField(max_length=10, choices=StatusChoices.choices, default=StatusChoices.DRAFT) + capacity = models.PositiveIntegerField(null=True, blank=True, + help_text="Maximum number of attendees (leave blank for unlimited)") + + price = models.IntegerField(default=0, help_text="Price of the event. Leave blank for free events.") + + registration_start_date = models.DateTimeField(null=True, blank=True) + registration_end_date = models.DateTimeField(null=True, blank=True) + featured_image = models.ImageField(upload_to='events/featured/', null=True, blank=True) + gallery_images = models.ManyToManyField('gallery.Gallery', blank=True, related_name='event_galleries', + help_text="Images taken during or related to the event.") + + registration_success_markdown = models.TextField( + blank=True, null=True, + help_text="Optional markdown shown to users after a successful registration." + ) + + class Meta: + ordering = ['-start_time'] + indexes = [ + models.Index(fields=['status', 'start_time']), + models.Index(fields=['event_type']), + ] + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + if not self.slug: + self.slug = slugify(self.title) + super().save(*args, **kwargs) + + @property + def description_html(self): + """Convert markdown description to HTML""" + return markdown.markdown( + self.description, + extensions=[ + 'markdown.extensions.extra', + 'markdown.extensions.toc', + ] + ) + + @property + def is_registration_open(self): + now = timezone.now() + return (self.registration_start_date is None or now >= self.registration_start_date) and \ + (self.registration_end_date is None or now <= self.registration_end_date) + + @property + def current_attendees_count(self): + """Count confirmed attendees""" + return self.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED], is_deleted=False).count() + + @property + def has_available_slots(self): + """Check whether registration slots are available, treating None as unlimited capacity.""" + if self.capacity is None: + return True + return self.current_attendees_count < self.capacity + + +class Registration(BaseModel): + class StatusChoices(models.TextChoices): + PENDING = 'pending', 'Pending' + CONFIRMED = 'confirmed', 'Confirmed' + CANCELLED = 'cancelled', 'Cancelled' + ATTENDED = 'attended', 'Attended' + + event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='registrations') + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='event_registrations') + registered_at = models.DateTimeField(auto_now_add=True) + status = models.CharField(max_length=10, choices=StatusChoices.choices, + default=StatusChoices.PENDING) + ticket_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + + confirmation_email_sent_at = models.DateTimeField(null=True, blank=True) + cancellation_email_sent_at = models.DateTimeField(null=True, blank=True) + discount_code = models.ForeignKey( + "payments.DiscountCode", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="registrations", + ) + discount_amount = models.PositiveIntegerField(default=0) + final_price = models.PositiveIntegerField(null=True, blank=True) + + class Meta: + ordering = ['-registered_at'] + indexes = [ + models.Index(fields=['event', 'status']), + models.Index(fields=['user']), + ] + + def __str__(self): + return f"{self.user.username} registered for {self.event.title}" + + @property + def status_label(self): + """Human-readable label for the current registration status.""" + return self.get_status_display() + + def save(self, *args, **kwargs): + # detect create vs update + is_create = self._state.adding + old_status = None + + if not is_create and self.pk: + old_status = ( + self.__class__.objects.only("status").get(pk=self.pk).status + ) + + # save first (so we have a pk + final values) + super().save(*args, **kwargs) + + # 1) on create -> send confirmation if pending/confirmed (and not sent before) + if is_create and self.status == self.StatusChoices.CONFIRMED and not self.confirmation_email_sent_at: + # lazy import to avoid circular import + from events.tasks import send_registration_confirmation_email + send_registration_confirmation_email.delay(str(self.pk)) + self.confirmation_email_sent_at = timezone.now() + super().save(update_fields=["confirmation_email_sent_at"]) + + # 2) status changed -> cancelled + if (not is_create) and (old_status != self.StatusChoices.CANCELLED) and (self.status == self.StatusChoices.CANCELLED) and (not self.cancellation_email_sent_at): + from events.tasks import send_registration_cancellation_email + send_registration_cancellation_email.delay(str(self.pk)) + self.cancellation_email_sent_at = timezone.now() + super().save(update_fields=["cancellation_email_sent_at"]) + + # 3) status changed -> confirmed (if not sent before) + if (not is_create) and (old_status != self.StatusChoices.CONFIRMED) and (self.status == self.StatusChoices.CONFIRMED) and (not self.confirmation_email_sent_at): + from events.tasks import send_registration_confirmation_email + send_registration_confirmation_email.delay(str(self.pk)) + self.confirmation_email_sent_at = timezone.now() + super().save(update_fields=["confirmation_email_sent_at"]) + + +class EventEmailLog(BaseModel): + class KindChoices(models.TextChoices): + INVITE_NON_REGISTERED = "invite_non_registered", "Invite non-registered users" + SKYROOM_CREDENTIALS = "send_skyroom_credentials", "Skyroom credentials" + EVENT_ANNOUNCEMENT = "send_event_announcement", "Event announcement" + EVENT_ANNOUNCEMENT2 = "send_event_announcement2", "Event announcement 2" + EVENT_ANNOUNCEMENT3 = "send_event_announcement3", "Event announcement 3" + EVENT_REMINDER = "send_event_reminder", "Event reminder" + + class StatusChoices(models.TextChoices): + PENDING = "pending", "Pending" + SENT = "sent", "Sent" + FAILED = "failed", "Failed" + + KIND_INVITE_NON_REGISTERED = KindChoices.INVITE_NON_REGISTERED + KIND_SKYROOM_CREDENTIALS = KindChoices.SKYROOM_CREDENTIALS + KIND_EVENT_ANNOUNCEMENT = KindChoices.EVENT_ANNOUNCEMENT + KIND_EVENT_ANNOUNCEMENT2 = KindChoices.EVENT_ANNOUNCEMENT2 + KIND_EVENT_ANNOUNCEMENT3 = KindChoices.EVENT_ANNOUNCEMENT3 + KIND_EVENT_REMINDER = KindChoices.EVENT_REMINDER + KIND_CHOICES = KindChoices.choices + + STATUS_PENDING = StatusChoices.PENDING + STATUS_SENT = StatusChoices.SENT + STATUS_FAILED = StatusChoices.FAILED + STATUS_CHOICES = StatusChoices.choices + + event = models.ForeignKey('events.Event', on_delete=models.CASCADE, related_name='email_logs') + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='email_logs') + kind = models.CharField(max_length=64, choices=KIND_CHOICES) + status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING) + error = models.TextField(blank=True, null=True) + sent_at = models.DateTimeField(blank=True, null=True) + context_hash = models.CharField(max_length=64, blank=True, null=True) + + class Meta: + unique_together = ("event", "user", "kind", "context_hash") + indexes = [ + models.Index(fields=["event", "kind", "status"]), + models.Index(fields=["user", "kind", "status"]), + ] + + def __str__(self): + return f"{self.event.id} - {self.user.id} - {self.kind} - {self.status}" + + @staticmethod + def _hash_context(context): + if context is None: + return None + if not isinstance(context, str): + context = str(context) + return hashlib.sha256(context.encode("utf-8")).hexdigest() + + @classmethod + def claim(cls, *, event_id, user_id, kind, context=None): + context_hash = cls._hash_context(context) + log, created = cls.objects.get_or_create( + event_id=event_id, + user_id=user_id, + kind=kind, + context_hash=context_hash, + defaults={"status": cls.STATUS_PENDING}, + ) + if not created and log.status in (cls.STATUS_PENDING, cls.STATUS_SENT): + return log, True + if not created: + log._commit_status(cls.STATUS_PENDING, error="") + return log, False + + def _commit_status(self, status, *, error="", sent_at=None): + self.status = status + self.error = error + update_fields = ["status", "error"] + if status == self.STATUS_SENT: + self.sent_at = sent_at or timezone.now() + update_fields.append("sent_at") + elif self.sent_at is not None: + self.sent_at = None + update_fields.append("sent_at") + if hasattr(self, "updated_at"): + update_fields.append("updated_at") + self.save(update_fields=update_fields) + + def mark_sent(self): + self._commit_status(self.STATUS_SENT) + + def mark_failed(self, error): + self._commit_status(self.STATUS_FAILED, error=error) diff --git a/backend/events/resources.py b/backend/events/resources.py new file mode 100644 index 0000000..3f695b9 --- /dev/null +++ b/backend/events/resources.py @@ -0,0 +1,86 @@ +from import_export import resources, fields +from import_export.widgets import ForeignKeyWidget, ManyToManyWidget + +from events.models import Event, Registration +from users.models import User +from gallery.models import Gallery +from payments.models import DiscountCode + +class EventResource(resources.ModelResource): + gallery_images = fields.Field( + column_name='gallery_images', + attribute='gallery_images', + widget=ManyToManyWidget(Gallery, field='title', separator='|') + ) + + class Meta: + model = Event + fields = ( + 'id', 'title', 'slug', 'description', 'start_time', 'end_time', + 'event_type', 'address', 'location', 'online_link', 'status', + 'capacity', 'price', 'registration_start_date', 'registration_end_date', + 'featured_image', 'gallery_images', 'created_at', 'updated_at', + 'is_deleted', 'deleted_at' + ) + export_order = fields + +class RegistrationResource(resources.ModelResource): + """Export registrations with user attributes and shortened ticket identifiers.""" + + event = fields.Field( + column_name='event', + attribute='event', + widget=ForeignKeyWidget(Event, 'title') + ) + user_username = fields.Field( + column_name='user_username', + attribute='user', + widget=ForeignKeyWidget(User, 'username') + ) + user_email = fields.Field( + column_name='user_email', + attribute='user', + widget=ForeignKeyWidget(User, 'email') + ) + user_first_name = fields.Field( + column_name='user_first_name', + attribute='user', + widget=ForeignKeyWidget(User, 'first_name') + ) + user_last_name = fields.Field( + column_name='user_last_name', + attribute='user', + widget=ForeignKeyWidget(User, 'last_name') + ) + discount_code = fields.Field( + column_name='discount_code', + attribute='discount_code', + widget=ForeignKeyWidget(DiscountCode, 'code') + ) + + class Meta: + model = Registration + fields = ( + 'id', + 'event', + 'user_username', + 'user_email', + 'user_first_name', + 'user_last_name', + 'registered_at', + 'status', + 'ticket_id', + 'discount_code', + 'discount_amount', + 'final_price', + 'created_at', + 'updated_at', + 'is_deleted', + 'deleted_at', + ) + export_order = fields + + def dehydrate_ticket_id(self, obj): + """Limit ticket identifiers to eight characters in exports.""" + val = getattr(obj, 'ticket_id', '') + return str(val)[:8] if val else '' diff --git a/backend/events/tasks.py b/backend/events/tasks.py new file mode 100644 index 0000000..1f0f962 --- /dev/null +++ b/backend/events/tasks.py @@ -0,0 +1,584 @@ +from django.core.mail import EmailMultiAlternatives +from django.template.loader import render_to_string +from django.utils.html import strip_tags +from django.conf import settings +from django.utils import timezone + +from celery import shared_task, group +from celery.exceptions import SoftTimeLimitExceeded +import markdown +import logging + +from users.models import User +from events.models import Event, Registration, EventEmailLog +from utils.templatetags.jalali import fa_digits, jdate + + +logger = logging.getLogger(__name__) +ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS = 30 +ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS = 45 + +@shared_task(bind=True, max_retries=3) +def send_registration_confirmation_email(self, registration_pk: str): + """Send a registration confirmation email, loading the model lazily to avoid circular imports.""" + try: + from .models import Registration + reg = ( + Registration.objects + .select_related("event", "user") + .get(pk=registration_pk) + ) + + user_email = getattr(reg.user, "email", None) + if not user_email: + return + + success_md = reg.event.registration_success_markdown or "" + success_html = markdown.markdown( + success_md, + extensions=["extra", "sane_lists", "toc"] + ) if success_md else "" + + context = { + "user": reg.user, + "event": reg.event, + "registration": reg, + "success_html": success_html, + } + + subject = f"تأیید ثبت‌نام شما در {reg.event.title}" + html_body = render_to_string("emails/event_registration_confirmation.html", context) + plain_body = strip_tags(html_body) + + message = EmailMultiAlternatives( + subject=subject, + body=plain_body, + from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None), + to=[user_email], + ) + message.attach_alternative(html_body, "text/html") + message.send(fail_silently=False) + logger.info(f"Event Confirm Registration email sent to {reg.user.email}") + + except Exception as exc: + logger.error(f"Failed to send event registration email: {exc}") + raise self.retry(exc=exc, countdown=60) + + +@shared_task(bind=True, max_retries=3) +def send_registration_cancellation_email(self, registration_pk: str): + try: + from .models import Registration + reg = ( + Registration.objects + .select_related("event", "user") + .get(pk=registration_pk) + ) + + user_email = getattr(reg.user, "email", None) + if not user_email: + return + + context = { + "user": reg.user, + "event": reg.event, + "registration": reg, + } + + subject = f"لغو ثبت‌نام شما در {reg.event.title}" + html_body = render_to_string("emails/event_registration_cancellation.html", context) + plain_body = strip_tags(html_body) + + message = EmailMultiAlternatives( + subject=subject, + body=plain_body, + from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None), + to=[user_email], + ) + message.attach_alternative(html_body, "text/html") + message.send(fail_silently=False) + logger.info(f"Event Confirm Registration email sent to {reg.user.email}") + + except Exception as exc: + logger.error(f"Failed to send event registration email: {exc}") + raise self.retry(exc=exc, countdown=60) + + +def _event_recipients(event, statuses=None, only_verified=True): + qs = Registration.objects.filter(event=event, is_deleted=False) + if statuses: + qs = qs.filter(status__in=statuses) + if only_verified: + qs = qs.filter(user__is_email_verified=True) + + qs = qs.exclude(user__email__isnull=True).exclude(user__email="") + return qs.select_related("user") + + +def _send_html_email(subject, html_body, to_email): + text_body = strip_tags(html_body) + msg = EmailMultiAlternatives( + subject=subject, + body=text_body, + from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None), + to=[to_email], + ) + msg.attach_alternative(html_body, "text/html") + msg.send() + + +def _build_email_context(*parts): + values = [str(part) for part in parts if part not in (None, "")] + return "|".join(values) if values else None + + +@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_kwargs={"max_retries": 3}, soft_time_limit=60) +def send_skyroom_credentials_individual_task(self, reg_id: int): + """ + ارسال نام‌کاربری/رمز برای اسکای‌روم + - username = user.email + - password = registration.ticket_id[:8] + - url = event.online_link (اگر لینک در فیلد online_link ذخیره شده باشد) + """ + r = Registration.objects.get(pk=reg_id) + event = r.event + user = r.user + sky_user = user.email.strip().split('@')[0] + sky_pass = str(r.ticket_id)[:8] + skyroom_url = event.online_link + try: + ctx = { + "user": user, + "event": event, + "skyroom_url": skyroom_url, + "sky_username": sky_user, + "sky_password": sky_pass, + "event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}", + } + subject = f"اطلاعات دسترسی اسکای‌روم - {event.title}" + html = render_to_string("emails/skyroom_credentials.html", ctx) + text_body = strip_tags(html) + msg = EmailMultiAlternatives( + subject=subject, + body=text_body, + from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None), + to=[user.email], + ) + msg.attach_alternative(html, "text/html") + msg.send() + logger.info(f'Skyroom Credentials for Event "{event.title}" sent to {user.email}') + + except Exception as exc: + logger.error(f"Failed to send skyroom credentials email: {exc}") + raise self.retry(exc=exc, countdown=60) + + +@shared_task(bind=True) +def send_event_reminder_task(self, event_id: int): + """ + یادآوری رویداد (ارسال الان؛ برای ارسال خودکار یک روز قبل، یک beat job بسازید) + """ + event = Event.objects.get(pk=event_id) + regs = ( + _event_recipients(event, statuses=["confirmed", "attended"]) + .select_related("user", "event") + .distinct() + ) + reg_ids = list(regs.values_list("id", flat=True)) + + job = group(send_event_reminder_to_user.s(event_id, rid) for rid in reg_ids) + res = job.apply_async() + + logger.info( + 'Queued %s event reminder emails for event "%s" (group_id=%s)', + len(reg_ids), + event.title, + res.id, + ) + return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id} + + +@shared_task( + bind=True, + autoretry_for=(Exception,), + retry_backoff=True, + retry_jitter=True, + retry_kwargs={"max_retries": 3}, + soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS, + time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS, +) +def send_event_reminder_to_user(self, event_id: int, registration_id: int): + """ + Send reminder email to a single registration; safe to retry without duplicating emails. + """ + user = None + log = None + + try: + r = Registration.objects.select_related("user", "event").get(pk=registration_id) + user = r.user + event = r.event + + to_email = (user.email or "").strip() + if not to_email: + return {"skipped": True, "status": "no_email"} + + context_key = _build_email_context( + "event_reminder", + event.slug or event.id, + event.start_time, + ) + log, skip = EventEmailLog.claim( + event_id=event_id, + user_id=user.id, + kind=EventEmailLog.KIND_EVENT_REMINDER, + context=context_key, + ) + if skip: + return {"skipped": True, "status": log.status} + + ctx = { + "user": user, + "event": event, + "event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}", + } + + subject = f"یادآوری رویداد: {event.title}" + html = render_to_string("emails/event_reminder.html", ctx) + text_body = strip_tags(html) + msg = EmailMultiAlternatives( + subject=subject, + body=text_body, + from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None), + to=[to_email], + ) + msg.attach_alternative(html, "text/html") + msg.send() + + log.mark_sent() + logger.info('Event reminder for "%s" sent to %s', event.title, to_email) + return f"Email sent to {to_email}" + + except SoftTimeLimitExceeded: + if log: + log.mark_failed("Soft time limit exceeded") + logger.warning( + "Soft time limit exceeded (event_id=%s, registration_id=%s)", + event_id, + registration_id, + ) + raise + + except Exception as exc: + if log: + log.mark_failed(str(exc)) + logger.error( + "Failed to send event reminder email: %s", exc, exc_info=True + ) + raise + + +@shared_task(bind=True) +def queue_event_announcement(self, event_id: int, subject: str, body_html: str, statuses=None): + """ + تسک مادر: ثبت‌نام‌های هدف را پیدا می‌کند و برای هر Registration یک تسک کوچک می‌سازد. + """ + event = Event.objects.get(pk=event_id) + + # محدوده مخاطبان: اگر statuses داده نشد، همان پیش‌فرض قبلی شما + statuses = statuses or ["confirmed", "attended", "pending"] + + regs = ( + _event_recipients(event, statuses=statuses) + .select_related("user", "event") + .exclude(user__email__isnull=True) + .exclude(user__email="") + .distinct() + ) + + reg_ids = list(regs.values_list("id", flat=True)) + + # ساخت group از تسک‌های کوچک؛ هر کدام فقط یک ایمیل ارسال می‌کند + job = group( + send_event_announcement_to_user.s(event_id, rid, subject, body_html) + for rid in reg_ids + ) + + # اگر نتیجه‌ها لازم نیست: CELERY_TASK_IGNORE_RESULT = True + res = job.apply_async() + logger.info( + 'Queued %s event-announcement emails for event "%s" (group_id=%s)', + len(reg_ids), event.title, res.id + ) + return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id} + +@shared_task( + bind=True, + autoretry_for=(Exception,), + retry_backoff=True, + retry_jitter=True, + retry_kwargs={"max_retries": 3}, + soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS, + time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS, +) +def send_event_announcement_to_user(self, event_id: int, registration_id: int, subject: str, body_html: str): + """ + تسک کوچک و اتمی: ارسال ایمیل اعلان رویداد برای یک Registration. + با لاگ ایدمپوتنسی تا ارسال تکراری نداشته باشیم. + """ + user = None + log = None + + try: + # از Registration می‌گیریم تا یک کوئری کمتر به Event بزنیم + r = Registration.objects.select_related("user", "event").get(pk=registration_id) + user = r.user + event = r.event + + context_key = _build_email_context( + "event_announcement3", + event.slug or event.id, + subject, + body_html, + ) + log, skip = EventEmailLog.claim( + event_id=event_id, + user_id=user.id, + kind=EventEmailLog.KIND_EVENT_ANNOUNCEMENT3, + context=context_key, + ) + if skip: + return {"skipped": True, "status": log.status} + + # کانتکست رندر ایمیل: body_html مستقیم داخل تمپلیت شما اینجکت می‌شود + ctx = { + "user": user, + "event": event, + "body_html": body_html, + "event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}", + } + + html = render_to_string("emails/event_announcement.html", ctx) + text_body = strip_tags(html) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_body, + from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None), + to=[user.email], + ) + msg.attach_alternative(html, "text/html") + msg.send() + + log.mark_sent() + + logger.info('Event announcement for "%s" sent to %s', event.title, user.email) + return f"Email sent to {user.email}" + + except SoftTimeLimitExceeded: + if log: + log.mark_failed("Soft time limit exceeded") + logger.warning("Soft time limit exceeded (event_id=%s, registration_id=%s)", event_id, registration_id) + raise + + except Exception as exc: + if log: + log.mark_failed(str(exc)) + logger.error("Failed to send event announcement email: %s", exc, exc_info=True) + raise + + +def _event_url(event): + root = getattr(settings, "FRONTEND_ROOT", "/") + slug_or_id = getattr(event, "slug", None) or event.id + return f"{root}events/{slug_or_id}" + +@shared_task(bind=True) +def queue_invites_to_non_registered_users(self, event_id: int, only_verified=True, only_active=True): + """ + تسک مادر: فقط کاربرها را پیدا می‌کند و برای هر نفر یک تسک کوچک می‌سازد. + """ + event = Event.objects.get(pk=event_id) + + qs = User.objects.all() + if only_verified: + qs = qs.filter(is_email_verified=True) + if only_active: + qs = qs.filter(is_active=True) + + # کسانی که برای این ایونت ثبت‌نام نکرده‌اند + qs = qs.exclude(event_registrations__event_id=event_id) \ + .exclude(email__isnull=True).exclude(email="") \ + .distinct() + + user_ids = list(qs.values_list("id", flat=True)) + + # گَروهِ تسک‌های کوچک + job = group(send_invite_to_user.s(event_id, uid) for uid in user_ids) + res = job.apply_async() + return {"event_id": event_id, "queued": len(user_ids), "group_id": res.id} + +@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_jitter=True, retry_kwargs={"max_retries": 3}, time_limit=60) +def send_invite_to_user(self, event_id: int, user_id: int): + """ + تسک کوچک و اتمی: برای هر کاربر حداکثر یک ایمیل می‌فرستد (با لاگ ایدمپوتنسی). + """ + event = Event.objects.get(pk=event_id) + user = User.objects.get(pk=user_id) + + # ساخت محتوا + context = { + "user": user, + "event": event, + "event_url": _event_url(event), + "start_time": fa_digits(jdate(event.start_time)) + } + # ایدمپوتنسی: اگر قبلاً این ایمیل رزرو/ارسال شده، Skip + subject = f"دعوت به شرکت در «{event.title}»" + text_body = render_to_string("emails/event_invite_non_registered.txt", context) + html_body = render_to_string("emails/event_invite_non_registered.html", context) + context_key = _build_email_context( + "invite_non_registered", + event.slug or event.id, + html_body, + ) + log, skip = EventEmailLog.claim( + event_id=event_id, + user_id=user_id, + kind=EventEmailLog.KIND_INVITE_NON_REGISTERED, + context=context_key, + ) + if skip: + return {"skipped": True, "status": log.status} + + try: + msg = EmailMultiAlternatives( + subject=subject, + body=text_body, + from_email=settings.DEFAULT_FROM_EMAIL, + to=[user.email], + ) + msg.attach_alternative(html_body, "text/html") + msg.send() + + log.mark_sent() + return f"Email sent to {user.email}" + except Exception as exc: + log.mark_failed(str(exc)) + raise + + +@shared_task(bind=True) +def queue_skyroom_credentials(self, event_id: int): + """ + تسک مادر: ثبت‌نام‌های تاییدشده را پیدا می‌کند و برای هر Registration یک تسک کوچک می‌سازد. + """ + event = Event.objects.get(pk=event_id) + + # فقط CONFIRMED ها + ایمیل معتبر + regs = ( + _event_recipients(event, statuses=[Registration.StatusChoices.CONFIRMED]) + .select_related("user", "event") + .exclude(user__email__isnull=True) + .exclude(user__email="") + .distinct() + ) + + reg_ids = list(regs.values_list("id", flat=True)) + + # ساخت group از تسک‌های کوچک؛ هر کدوم فقط یک ایمیل ارسال می‌کنند + job = group(send_skyroom_credentials_to_user.s(event_id, rid) for rid in reg_ids) + + # توصیه: اگر نتیجه‌ها را لازم ندارید، در تنظیمات CELERY_TASK_IGNORE_RESULT=True بگذارید + res = job.apply_async() + logger.info( + 'Queued %s Skyroom-credential emails for event "%s" (group_id=%s)', + len(reg_ids), event.title, res.id + ) + return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id} + + +@shared_task( + bind=True, + autoretry_for=(Exception,), + retry_backoff=True, + retry_jitter=True, + retry_kwargs={"max_retries": 3}, + soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS, + time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS, +) +def send_skyroom_credentials_to_user(self, event_id: int, registration_id: int): + """ + تسک کوچک و اتمی: ارسال نام‌کاربری/رمز اسکای‌روم برای یک Registration. + با لاگ ایدمپوتنسی تا ارسال تکراری نداشته باشیم. + """ + user = None + log = None + + try: + r = Registration.objects.select_related("user", "event").get(pk=registration_id) + user = r.user + event = r.event + + # ساخت یوزرنیم/پسورد + sky_username = (user.email or "").strip().split("@")[0] + sky_password = str(r.ticket_id or "")[:8] + skyroom_url = event.online_link + + context_key = _build_email_context( + "skyroom_credentials", + event.slug or event.id, + sky_username, + sky_password, + skyroom_url, + ) + log, skip = EventEmailLog.claim( + event_id=event_id, + user_id=user.id, + kind=EventEmailLog.KIND_SKYROOM_CREDENTIALS, + context=context_key, + ) + if skip: + return {"skipped": True, "status": log.status} + + ctx = { + "user": user, + "event": event, + "skyroom_url": skyroom_url, + "sky_username": sky_username, + "sky_password": sky_password, + "event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}", + } + + subject = f"اطلاعات دسترسی اسکای‌روم - {event.title}" + html = render_to_string("emails/skyroom_credentials.html", ctx) + text_body = strip_tags(html) + + msg = EmailMultiAlternatives( + subject=subject, + body=text_body, + from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None), + to=[user.email], + ) + msg.attach_alternative(html, "text/html") + msg.send() + + log.mark_sent() + + logger.info('Skyroom credentials for "%s" sent to %s', event.title, user.email) + return f"Email sent to {user.email}" + + except SoftTimeLimitExceeded as exc: + # ثبت خطا و اجازه به Celery برای retry خودکار + if log: + log.mark_failed("Soft time limit exceeded") + logger.warning( + "Soft time limit exceeded for event_id=%s, registration_id=%s", event_id, registration_id + ) + raise + + except Exception as exc: + if log: + log.mark_failed(str(exc)) + logger.error("Failed to send skyroom credentials email: %s", exc, exc_info=True) + raise diff --git a/backend/gallery/admin.py b/backend/gallery/admin.py new file mode 100644 index 0000000..661fcb3 --- /dev/null +++ b/backend/gallery/admin.py @@ -0,0 +1,89 @@ +from django.contrib import admin +from django.utils.html import format_html + +from import_export.admin import ImportExportModelAdmin + +from gallery.models import Gallery +from gallery.resources import GalleryResource +from utils.admin import SoftDeleteListFilter, BaseModelAdmin + +@admin.register(Gallery) +class GalleryAdmin(BaseModelAdmin, ImportExportModelAdmin): + resource_class = GalleryResource + list_display = ('title', 'image_preview', 'uploaded_by', 'file_size_display', 'dimensions', 'is_public', 'created_at') + list_filter = ('is_public', 'created_at', SoftDeleteListFilter) + search_fields = ('title', 'description', 'alt_text') + readonly_fields = ('uploaded_by', 'file_size', 'width', 'height', 'image_preview_large', 'markdown_url') + + fieldsets = ( + ('Image Info', { + 'fields': ('title', 'description', 'image', 'alt_text', 'is_public') + }), + ('Uploader', { + 'fields': ('uploaded_by',), + 'classes': ('collapse',) + }), + ('Metadata', { + 'fields': ('file_size', 'width', 'height'), + 'classes': ('collapse',) + }), + ('Preview & Usage', { + 'fields': ('image_preview_large', 'markdown_url'), + 'classes': ('collapse',) + }), + ('Soft Delete', { + 'fields': ('is_deleted', 'deleted_at'), + 'classes': ('collapse',) + }), + ) + + actions = BaseModelAdmin.actions + ['make_public', 'make_private', 'restore_images'] + + def image_preview(self, obj): + if obj.image: + return format_html( + '', + obj.image.url + ) + return "No Image" + image_preview.short_description = "Preview" + + def image_preview_large(self, obj): + if obj.image: + return format_html( + '', + obj.image.url + ) + return "No Image" + image_preview_large.short_description = "Image Preview" + + def file_size_display(self, obj): + return f"{obj.file_size_mb} MB" if obj.file_size else "Unknown" + file_size_display.short_description = "File Size" + + def dimensions(self, obj): + if obj.width and obj.height: + return f"{obj.width} × {obj.height}" + return "Unknown" + dimensions.short_description = "Dimensions" + + def make_public(self, request, queryset): + queryset.update(is_public=True) + self.message_user(request, f"Made {queryset.count()} images public.") + make_public.short_description = "Make selected images public" + + def make_private(self, request, queryset): + queryset.update(is_public=False) + self.message_user(request, f"Made {queryset.count()} images private.") + make_private.short_description = "Make selected images private" + + def restore_images(self, request, queryset): + for image in queryset: + image.restore() + self.message_user(request, f"Restored {queryset.count()} images.") + restore_images.short_description = "Restore selected images" + + def save_model(self, request, obj, form, change): + if not obj.uploaded_by_id: + obj.uploaded_by = request.user + super().save_model(request, obj, form, change) diff --git a/backend/gallery/apps.py b/backend/gallery/apps.py new file mode 100644 index 0000000..7cd9bd1 --- /dev/null +++ b/backend/gallery/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class GalleryConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'gallery' diff --git a/backend/gallery/fixtures/gallery.json b/backend/gallery/fixtures/gallery.json new file mode 100644 index 0000000..d34cf14 --- /dev/null +++ b/backend/gallery/fixtures/gallery.json @@ -0,0 +1,218 @@ +[ + { + "model": "gallery.gallery", + "pk": 1, + "fields": { + "created_at": "2024-01-15T10:30:00Z", + "updated_at": "2024-01-15T10:30:00Z", + "is_deleted": false, + "title": "کارگاه یادگیری ماشین - تصویر ۱", + "description": "شرکت‌کنندگان در حال یادگیری مفاهیم یادگیری ماشین", + "image": "gallery/ml_workshop_1.jpg", + "uploaded_by": 1, + "alt_text": "دانشجویان در کارگاه یادگیری ماشین", + "file_size": 2048000, + "width": 1920, + "height": 1080, + "is_public": true + } + }, + { + "model": "gallery.gallery", + "pk": 2, + "fields": { + "created_at": "2024-01-20T14:15:00Z", + "updated_at": "2024-01-20T14:15:00Z", + "is_deleted": false, + "title": "مسابقه برنامه‌نویسی - لحظه اعلام نتایج", + "description": "اعلام نتایج مسابقه برنامه‌نویسی و اهدای جوایز", + "image": "gallery/programming_contest_results.jpg", + "uploaded_by": 2, + "alt_text": "اهدای جوایز مسابقه برنامه‌نویسی", + "file_size": 1536000, + "width": 1600, + "height": 900, + "is_public": true + } + }, + { + "model": "gallery.gallery", + "pk": 3, + "fields": { + "created_at": "2024-01-25T09:45:00Z", + "updated_at": "2024-01-25T09:45:00Z", + "is_deleted": false, + "title": "سمینار امنیت سایبری", + "description": "دکتر رضایی در حال ارائه مطالب امنیت سایبری", + "image": "gallery/cybersecurity_seminar.jpg", + "uploaded_by": 5, + "alt_text": "سخنرانی در سمینار امنیت سایبری", + "file_size": 1792000, + "width": 1800, + "height": 1200, + "is_public": true + } + }, + { + "model": "gallery.gallery", + "pk": 4, + "fields": { + "created_at": "2024-02-01T16:20:00Z", + "updated_at": "2024-02-01T16:20:00Z", + "is_deleted": false, + "title": "کارگاه React.js - کدنویسی عملی", + "description": "شرکت‌کنندگان در حال کدنویسی با React.js", + "image": "gallery/react_workshop_coding.jpg", + "uploaded_by": 9, + "alt_text": "کدنویسی در کارگاه React.js", + "file_size": 2304000, + "width": 2048, + "height": 1152, + "is_public": true + } + }, + { + "model": "gallery.gallery", + "pk": 5, + "fields": { + "created_at": "2024-02-05T11:30:00Z", + "updated_at": "2024-02-05T11:30:00Z", + "is_deleted": false, + "title": "بازدید از دیجی‌کالا - ورودی شرکت", + "description": "دانشجویان در ورودی شرکت دیجی‌کالا", + "image": "gallery/digikala_visit_entrance.jpg", + "uploaded_by": 3, + "alt_text": "بازدید از شرکت دیجی‌کالا", + "file_size": 1920000, + "width": 1920, + "height": 1280, + "is_public": true + } + }, + { + "model": "gallery.gallery", + "pk": 6, + "fields": { + "created_at": "2024-02-10T22:45:00Z", + "updated_at": "2024-02-10T22:45:00Z", + "is_deleted": false, + "title": "هکاتون هوش مصنوعی - شب اول", + "description": "تیم‌ها در حال کار شبانه روزی در هکاتون", + "image": "gallery/ai_hackathon_night.jpg", + "uploaded_by": 6, + "alt_text": "کار شبانه در هکاتون هوش مصنوعی", + "file_size": 1664000, + "width": 1600, + "height": 1067, + "is_public": true + } + }, + { + "model": "gallery.gallery", + "pk": 7, + "fields": { + "created_at": "2024-02-15T13:10:00Z", + "updated_at": "2024-02-15T13:10:00Z", + "is_deleted": false, + "title": "سمینار کارآفرینی - پنل بحث", + "description": "پنل بحث با کارآفرینان موفق فناوری", + "image": "gallery/entrepreneurship_panel.jpg", + "uploaded_by": 1, + "alt_text": "پنل بحث کارآفرینی فناوری", + "file_size": 2176000, + "width": 1920, + "height": 1080, + "is_public": true + } + }, + { + "model": "gallery.gallery", + "pk": 8, + "fields": { + "created_at": "2024-02-20T15:25:00Z", + "updated_at": "2024-02-20T15:25:00Z", + "is_deleted": false, + "title": "کارگاه DevOps - آموزش Docker", + "description": "آموزش عملی Docker و کانتینرها", + "image": "gallery/devops_docker_training.jpg", + "uploaded_by": 8, + "alt_text": "آموزش Docker در کارگاه DevOps", + "file_size": 1856000, + "width": 1728, + "height": 1152, + "is_public": true + } + }, + { + "model": "gallery.gallery", + "pk": 9, + "fields": { + "created_at": "2024-02-25T12:40:00Z", + "updated_at": "2024-02-25T12:40:00Z", + "is_deleted": false, + "title": "مسابقه طراحی UI/UX - آثار شرکت‌کنندگان", + "description": "نمایش آثار طراحی شده توسط شرکت‌کنندگان", + "image": "gallery/uiux_contest_designs.jpg", + "uploaded_by": 12, + "alt_text": "آثار مسابقه طراحی UI/UX", + "file_size": 2048000, + "width": 2048, + "height": 1365, + "is_public": true + } + }, + { + "model": "gallery.gallery", + "pk": 10, + "fields": { + "created_at": "2024-03-01T17:55:00Z", + "updated_at": "2024-03-01T17:55:00Z", + "is_deleted": false, + "title": "نشست فارغ‌التحصیلان - عکس گروهی", + "description": "عکس یادگاری با فارغ‌التحصیلان و دانشجویان فعلی", + "image": "gallery/alumni_group_photo.jpg", + "uploaded_by": 5, + "alt_text": "عکس گروهی نشست فارغ‌التحصیلان", + "file_size": 2560000, + "width": 2560, + "height": 1440, + "is_public": true + } + }, + { + "model": "gallery.gallery", + "pk": 11, + "fields": { + "created_at": "2024-03-05T08:20:00Z", + "updated_at": "2024-03-05T08:20:00Z", + "is_deleted": false, + "title": "آزمایشگاه کامپیوتر - محیط کار", + "description": "نمایی از آزمایشگاه کامپیوتر دانشکده", + "image": "gallery/computer_lab.jpg", + "uploaded_by": 9, + "alt_text": "آزمایشگاه کامپیوتر دانشکده", + "file_size": 1792000, + "width": 1792, + "height": 1024, + "is_public": true + } + }, + { + "model": "gallery.gallery", + "pk": 12, + "fields": { + "created_at": "2024-03-10T14:35:00Z", + "updated_at": "2024-03-10T14:35:00Z", + "is_deleted": false, + "title": "کتابخانه دانشکده - بخش کتب فنی", + "description": "بخش کتب فنی و مهندسی کامپیوتر کتابخانه", + "image": "gallery/library_tech_books.jpg", + "uploaded_by": 4, + "alt_text": "کتب فنی کتابخانه دانشکده", + "file_size": 1536000, + "width": 1536, + "height": 1024, + "is_public": true + } + } +] diff --git a/backend/gallery/migrations/0001_initial.py b/backend/gallery/migrations/0001_initial.py new file mode 100644 index 0000000..f8bbe49 --- /dev/null +++ b/backend/gallery/migrations/0001_initial.py @@ -0,0 +1,36 @@ +# Generated by Django 5.2.5 on 2025-10-16 12:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Gallery', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('title', models.CharField(max_length=200)), + ('description', models.TextField(blank=True)), + ('image', models.ImageField(upload_to='gallery/')), + ('alt_text', models.CharField(blank=True, max_length=200)), + ('file_size', models.PositiveIntegerField(blank=True, null=True)), + ('width', models.PositiveIntegerField(blank=True, null=True)), + ('height', models.PositiveIntegerField(blank=True, null=True)), + ('is_public', models.BooleanField(default=True)), + ], + options={ + 'verbose_name_plural': 'Gallery Images', + 'ordering': ['-created_at'], + }, + ), + ] diff --git a/backend/gallery/migrations/0002_initial.py b/backend/gallery/migrations/0002_initial.py new file mode 100644 index 0000000..3683b96 --- /dev/null +++ b/backend/gallery/migrations/0002_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.5 on 2025-10-16 12:07 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('gallery', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='gallery', + name='uploaded_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='gallery_images', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/gallery/migrations/__init__.py b/backend/gallery/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/gallery/models.py b/backend/gallery/models.py new file mode 100644 index 0000000..fcc241b --- /dev/null +++ b/backend/gallery/models.py @@ -0,0 +1,82 @@ +from django.db import models +from django.conf import settings + +from PIL import Image + +from utils.models import BaseModel + + +MAX_IMAGE_FILE_SIZE_BYTES = 2 * 1024 * 1024 + +class Gallery(BaseModel): + title = models.CharField(max_length=200) + description = models.TextField(blank=True) + image = models.ImageField(upload_to='gallery/') + uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='gallery_images') + alt_text = models.CharField(max_length=200, blank=True) + file_size = models.PositiveIntegerField(null=True, blank=True) + width = models.PositiveIntegerField(null=True, blank=True) + height = models.PositiveIntegerField(null=True, blank=True) + is_public = models.BooleanField(default=True) + + class Meta: + ordering = ['-created_at'] + verbose_name_plural = "Gallery Images" + + def __str__(self): + return self.title + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + + if self.image: + # Get file size + self.file_size = self.image.size + + # Get image dimensions + with Image.open(self.image.path) as img: + self.width, self.height = img.size + + # Compress image if it's too large + self.compress_image() + + # Update fields without triggering save again + Gallery.objects.filter(pk=self.pk).update( + file_size=self.file_size, + width=self.width, + height=self.height + ) + + def compress_image(self): + """Compress image if it's larger than 2MB or dimensions are too large""" + if not self.image: + return + + with Image.open(self.image.path) as img: + # Convert to RGB if necessary + if img.mode in ("RGBA", "P"): + img = img.convert("RGB") + + # Resize if too large + max_size = (1920, 1080) + if img.size[0] > max_size[0] or img.size[1] > max_size[1]: + img.thumbnail(max_size, Image.Resampling.LANCZOS) + + # Compress if file size is too large + quality = 85 + if self.file_size and self.file_size > MAX_IMAGE_FILE_SIZE_BYTES: + quality = 70 + + img.save(self.image.path, "JPEG", quality=quality, optimize=True) + + @property + def file_size_mb(self): + """Return file size in MB""" + if self.file_size: + return round(self.file_size / (1024 * 1024), 2) + return 0 + + @property + def markdown_url(self): + """Return URL for use in markdown""" + return f"![{self.alt_text or self.title}]({settings.BACKEND_ROOT}{self.image.url})" diff --git a/backend/gallery/resources.py b/backend/gallery/resources.py new file mode 100644 index 0000000..b360196 --- /dev/null +++ b/backend/gallery/resources.py @@ -0,0 +1,17 @@ +from import_export import resources, fields +from import_export.widgets import ForeignKeyWidget + +from gallery.models import Gallery +from users.models import User + +class GalleryResource(resources.ModelResource): + uploaded_by = fields.Field( + column_name='uploaded_by', + attribute='uploaded_by', + widget=ForeignKeyWidget(User, 'username') + ) + + class Meta: + model = Gallery + fields = ('id', 'title', 'description', 'image', 'uploaded_by', + 'alt_text', 'file_size', 'width', 'height', 'is_public', 'created_at') diff --git a/backend/gallery/tasks.py b/backend/gallery/tasks.py new file mode 100644 index 0000000..0556633 --- /dev/null +++ b/backend/gallery/tasks.py @@ -0,0 +1,23 @@ +from celery import shared_task +from PIL import Image +import logging + +logger = logging.getLogger(__name__) + +@shared_task +def process_uploaded_image(gallery_id): + """Process uploaded image: compress, resize, extract metadata""" + try: + from .models import Gallery + gallery_item = Gallery.objects.get(id=gallery_id) + + if gallery_item.image: + # This will trigger the compression and metadata extraction + gallery_item.compress_image() + + logger.info(f"Processed image: {gallery_item.title}") + return f"Processed image: {gallery_item.title}" + + except Exception as exc: + logger.error(f"Failed to process image: {exc}") + raise exc diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 0000000..1709087 --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Django's command-line utility for administrative tasks.""" +import os +import sys + + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == '__main__': + main() diff --git a/backend/payments/admin.py b/backend/payments/admin.py new file mode 100644 index 0000000..05f6286 --- /dev/null +++ b/backend/payments/admin.py @@ -0,0 +1,83 @@ +from django.contrib import admin + +from import_export.admin import ImportExportModelAdmin + +from utils.admin import SoftDeleteListFilter, BaseModelAdmin +from payments.resources import DiscountResource, PaymentResource +from payments.models import Payment, DiscountCode + + +@admin.register(DiscountCode) +class DiscountCodeAdmin(BaseModelAdmin, ImportExportModelAdmin): + resource_class = DiscountResource + + list_display = ( + 'code', 'type', 'value', 'is_active', 'starts_at', 'ends_at', + 'usage_limit_total', 'usage_limit_per_user', 'min_amount', 'is_deleted' + ) + list_filter = ( + 'type', 'is_active', 'starts_at', 'ends_at', 'applicable_events', + SoftDeleteListFilter, + ) + search_fields = ('code', ) + readonly_fields = ('id', 'deleted_at', 'created_at', 'updated_at') + + fieldsets = ( + ('Discount Code Details', { + 'fields': ('code', 'type', 'value', 'applicable_events', 'is_active') + }), + ('Limitations', { + 'fields': ('starts_at', 'ends_at', 'usage_limit_total', 'usage_limit_per_user', 'min_amount') + }), + ('Soft Delete', { + 'fields': ('is_deleted', 'deleted_at'), + 'classes': ('collapse',) + }), + ) + + readonly_fields = ('deleted_at', ) + + actions = BaseModelAdmin.actions + [ + 'deactivate_codes', + ] + + @admin.action(description="Deactivate selected discount codes") + def deactivate_codes(self, request, queryset): + queryset.update(is_active=False) + self.message_user(request, f"Deactivate {queryset.count()} discount codes.") + +@admin.register(Payment) +class PaymentAdmin(BaseModelAdmin, ImportExportModelAdmin): + resource_class = PaymentResource + + list_display = ( + 'id', 'user', 'event', 'base_amount', 'discount_code', 'discount_amount', 'amount', + 'status', 'created_at', 'verified_at', 'is_deleted' + ) + list_filter = ( + 'status', 'event', + SoftDeleteListFilter, + ) + search_fields = ( + 'user__email', 'authority', 'ref_id', 'discount_code__code' + ) + readonly_fields = ( + 'user', 'event', 'base_amount', 'discount_code', 'discount_code', 'discount_amount', 'amount', 'authority', + 'status', 'ref_id', 'card_pan', 'card_hash', 'created_at', 'updated_at', 'deleted_at' + ) + + fieldsets = ( + ('Payment Details', { + 'fields': ('user', 'event', 'status', 'created_at', 'updated_at') + }), + ('Price Info', { + 'fields': ('base_amount', 'discount_code', 'discount_amount', 'amount') + }), + ('Others', { + 'fields': ('authority', 'ref_id', 'card_pan', 'card_hash') + }), + ('Soft Delete', { + 'fields': ('is_deleted', 'deleted_at'), + 'classes': ('collapse',) + }), + ) diff --git a/backend/payments/apps.py b/backend/payments/apps.py new file mode 100644 index 0000000..4886655 --- /dev/null +++ b/backend/payments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PaymentsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'payments' diff --git a/backend/payments/migrations/0001_initial.py b/backend/payments/migrations/0001_initial.py new file mode 100644 index 0000000..dcc1da7 --- /dev/null +++ b/backend/payments/migrations/0001_initial.py @@ -0,0 +1,64 @@ +# Generated by Django 5.2.5 on 2025-10-16 12:07 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('events', '0002_initial'), + ] + + operations = [ + migrations.CreateModel( + name='DiscountCode', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('code', models.CharField(max_length=64, unique=True)), + ('type', models.CharField(choices=[('percent', 'Percent'), ('fixed', 'Fixed (IRR)')], default='percent', max_length=10)), + ('value', models.PositiveIntegerField()), + ('max_discount', models.PositiveIntegerField(blank=True, null=True)), + ('is_active', models.BooleanField(default=True)), + ('starts_at', models.DateTimeField(blank=True, null=True)), + ('ends_at', models.DateTimeField(blank=True, null=True)), + ('usage_limit_total', models.PositiveIntegerField(blank=True, null=True)), + ('usage_limit_per_user', models.PositiveIntegerField(blank=True, null=True)), + ('min_amount', models.PositiveIntegerField(blank=True, null=True)), + ('applicable_events', models.ManyToManyField(blank=True, related_name='discount_codes', to='events.event')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='Payment', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('base_amount', models.PositiveIntegerField(editable=False)), + ('discount_amount', models.PositiveIntegerField(default=0, editable=False)), + ('amount', models.PositiveIntegerField(editable=False)), + ('authority', models.CharField(blank=True, editable=False, max_length=64, null=True, unique=True)), + ('status', models.IntegerField(choices=[(0, 'Initiated'), (1, 'Pending'), (2, 'Paid'), (3, 'Failed'), (4, 'Canceled')], default=0, editable=False)), + ('ref_id', models.CharField(blank=True, editable=False, max_length=64, null=True)), + ('card_pan', models.CharField(blank=True, editable=False, max_length=32, null=True)), + ('card_hash', models.CharField(blank=True, editable=False, max_length=128, null=True)), + ('verified_at', models.DateTimeField(blank=True, editable=False, null=True)), + ('discount_code', models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='payments.discountcode')), + ('event', models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='events.event')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/backend/payments/migrations/0002_initial.py b/backend/payments/migrations/0002_initial.py new file mode 100644 index 0000000..cda78c7 --- /dev/null +++ b/backend/payments/migrations/0002_initial.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.5 on 2025-10-16 12:07 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('payments', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name='payment', + name='user', + field=models.ForeignKey(editable=False, on_delete=django.db.models.deletion.PROTECT, related_name='payments', to=settings.AUTH_USER_MODEL), + ), + ] diff --git a/backend/payments/migrations/0003_payment_registration.py b/backend/payments/migrations/0003_payment_registration.py new file mode 100644 index 0000000..09a239b --- /dev/null +++ b/backend/payments/migrations/0003_payment_registration.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.13 on 2025-11-17 13:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0009_registration_discount_amount_and_more'), + ('payments', '0002_initial'), + ] + + operations = [ + migrations.AddField( + model_name='payment', + name='registration', + field=models.ForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='payments', to='events.registration'), + ), + ] diff --git a/backend/payments/migrations/__init__.py b/backend/payments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/payments/models.py b/backend/payments/models.py new file mode 100644 index 0000000..08b8fdb --- /dev/null +++ b/backend/payments/models.py @@ -0,0 +1,122 @@ +from django.db import models +from django.db.models import Q, Count +from django.core.exceptions import ValidationError +from django.conf import settings +from django.utils import timezone + +from utils.models import BaseModel +from events.models import Event + +from ninja.errors import HttpError + +User = settings.AUTH_USER_MODEL + + +class DiscountCode(BaseModel): + class Type(models.TextChoices): + PERCENT = "percent", "Percent" + FIXED = "fixed", "Fixed (IRR)" + + code = models.CharField(max_length=64, unique=True) + type = models.CharField(max_length=10, choices=Type.choices, default=Type.PERCENT) + value = models.PositiveIntegerField() + max_discount = models.PositiveIntegerField(null=True, blank=True) + is_active = models.BooleanField(default=True) + starts_at = models.DateTimeField(null=True, blank=True) + ends_at = models.DateTimeField(null=True, blank=True) + usage_limit_total = models.PositiveIntegerField(null=True, blank=True) + usage_limit_per_user = models.PositiveIntegerField(null=True, blank=True) + min_amount = models.PositiveIntegerField(null=True, blank=True) + applicable_events = models.ManyToManyField(Event, blank=True, related_name="discount_codes") + + def __str__(self): + return f"{self.code} ({self.get_type_display()} {self.value})" + + def calculate_discount(self, event: Event, user: User): + if not event.price: + return (0, 0) + + if not self.is_active: + raise HttpError(400, "کد تخفیف نامعتبر یا غیرفعال است.") + + n = timezone.now() + if self.starts_at and n < self.starts_at: + raise HttpError(400, "کد تخفیف هنوز فعال نشده است.") + if self.ends_at and n > self.ends_at: + raise HttpError(400, "کد تخفیف منقضی شده است.") + + if self.applicable_events.exists() and not self.applicable_events.filter(pk=event.pk).exists(): + raise HttpError(400, "کد تخفیف برای این رویداد قابل استفاده نیست.") + + if self.min_amount and event.price < self.min_amount: + raise HttpError(400, "مبلغ سفارش کمتر از حداقل لازم برای این کد است.") + + used_qs = Payment.objects.filter(discount_code=self, status__in=[Payment.OrderStatusChoices.PAID, Payment.OrderStatusChoices.PENDING]) + if self.usage_limit_total is not None and used_qs.count() >= self.usage_limit_total: + raise HttpError(400, "حداکثر تعداد استفاده از این کد تخفیف تکمیل شده است.") + + used_by_user = used_qs.filter(user=user).count() + if self.usage_limit_per_user is not None and used_by_user >= self.usage_limit_per_user: + raise HttpError(400, "شما حداکثر تعداد مجاز استفاده از این کد تخفیف را مصرف کرده‌اید.") + + if self.type == DiscountCode.Type.FIXED: + disc = min(self.value, event.price) + else: + disc = (event.price * self.value) // 100 + if self.max_discount: + disc = min(disc, self.max_discount) + + final_amount = max(event.price - disc, 0) + if 0 < final_amount < 10_000: + raise HttpError(400, "با این تخفیف مبلغ قابل پرداخت به کمتر از ۱۰٬۰۰۰ ریال می‌رسد.") + + return (final_amount, disc) + + +class Payment(BaseModel): + class OrderStatusChoices(models.IntegerChoices): + INIT = 0, "Initiated" + PENDING = 1, "Pending" + PAID = 2, "Paid" + FAILED = 3, "Failed" + CANCELED = 4, "Canceled" + + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payments', editable=False) + event = models.ForeignKey(Event, on_delete=models.PROTECT, related_name='payments', editable=False) + + base_amount = models.PositiveIntegerField(editable=False) + discount_code = models.ForeignKey(DiscountCode, on_delete=models.PROTECT, null=True, blank=True, editable=False, related_name="payments") + discount_amount = models.PositiveIntegerField(default=0, editable=False) + amount = models.PositiveIntegerField(editable=False) + + registration = models.ForeignKey( + "events.Registration", + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name="payments", + editable=False, + ) + authority = models.CharField(max_length=64, unique=True, null=True, blank=True, editable=False) + status = models.IntegerField(choices=OrderStatusChoices.choices, default=OrderStatusChoices.INIT, editable=False) + ref_id = models.CharField(max_length=64, null=True, blank=True, editable=False) + card_pan = models.CharField(max_length=32, null=True, blank=True, editable=False) + card_hash = models.CharField(max_length=128, null=True, blank=True, editable=False) + verified_at = models.DateTimeField(null=True, blank=True, editable=False) + + def clean(self): + if self.discount_amount and self.amount + self.discount_amount != self.base_amount: + raise ValidationError({"amount": "amount + discount_amount must equal base_amount"}) + + def save(self, *args, **kwargs): + self.full_clean() + return super().save(*args, **kwargs) + + @property + def status_label(self): + """Human-readable label for the payment status.""" + return self.get_status_display() + + def __str__(self): + return f"{self.user.email}:{self.event} - {self.get_status_display()}" + diff --git a/backend/payments/resources.py b/backend/payments/resources.py new file mode 100644 index 0000000..06f2ac1 --- /dev/null +++ b/backend/payments/resources.py @@ -0,0 +1,44 @@ +from import_export import resources, fields +from import_export.widgets import ForeignKeyWidget, ManyToManyWidget + +from payments.models import Payment, DiscountCode +from events.models import Event +from users.models import User + +class DiscountResource(resources.ModelResource): + event = fields.Field( + column_name='applicable_events', + attribute='applicable_events', + widget=ManyToManyWidget(Event, field='title', separator='||') + ) + + class Meta: + model = Event + fields = ( + 'id', 'code', 'type', 'value', 'max_discount', 'is_active', + 'starts_at', 'ends_at', 'usage_limit_total', 'usage_limit_per_user', + 'min_amount', 'applicable_events', 'created_at', 'updated_at', + 'is_deleted', 'deleted_at' + ) + export_order = fields + +class PaymentResource(resources.ModelResource): + event = fields.Field( + column_name='event', + attribute='event', + widget=ForeignKeyWidget(Event, 'title') + ) + user = fields.Field( + column_name='user', + attribute='user', + widget=ForeignKeyWidget(User, 'username') + ) + + class Meta: + model = Payment + fields = ( + 'id', 'event', 'user', 'base_amount', 'discount_code', 'discount_amount', 'amount', + 'authority', 'status', 'red_id', 'card_pan', 'card_hash', 'verified_at', 'created_at', + 'updated_at', 'is_deleted', 'deleted_at' + ) + export_order = fields diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..7bd8eee --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,71 @@ +aiohappyeyeballs==2.6.1 +aiohttp==3.12.15 +aiosignal==1.4.0 +amqp==5.3.1 +annotated-types==0.7.0 +asgiref==3.9.1 +attrs==25.3.0 +billiard==4.2.1 +celery==5.5.3 +certifi==2025.8.3 +cffi==1.17.1 +charset-normalizer==3.4.3 +click==8.2.1 +click-didyoumean==0.3.1 +click-plugins==1.1.1.2 +click-repl==0.3.0 +colorama==0.4.6 +cryptography==45.0.6 +diff-match-patch==20241021 +Django==5.2.5 +django-cors-headers==4.7.0 +django-import-export==4.3.9 +django-location-field==2.7.3 +django-ninja==1.4.3 +django-simplemde==0.1.4 +django-prometheus==2.4.1 +django-redis==6.0.0 +django-unfold==0.63.0 +dnspython==2.7.0 +email_validator==2.2.0 +flower==2.0.1 +frozenlist==1.7.0 +gunicorn==23.0.0 +http_ece==1.2.1 +humanize==4.12.3 +idna==3.10 +kombu==5.5.4 +Markdown==3.8.2 +multidict==6.6.4 +packaging==25.0 +pillow==11.3.0 +prometheus_client==0.22.1 +prompt_toolkit==3.0.51 +propcache==0.3.2 +psycopg2-binary==2.9.10 +py-vapid==1.9.2 +pycparser==2.22 +pydantic==2.11.7 +pydantic_core==2.33.2 +PyJWT==2.10.1 +python-dateutil==2.9.0.post0 +python-decouple==3.8 +python-multipart==0.0.20 +pytz==2025.2 +pywebpush==2.0.3 +redis==6.4.0 +requests==2.32.4 +setuptools==80.9.0 +six==1.17.0 +sqlparse==0.5.3 +tablib==3.8.0 +tornado==6.5.1 +typing-inspection==0.4.1 +typing_extensions==4.14.1 +tzdata==2025.2 +urllib3==2.5.0 +vine==5.1.0 +wcwidth==0.2.13 +whitenoise==6.9.0 +yarl==1.20.1 +jdatetime diff --git a/backend/static/css/styles.css b/backend/static/css/styles.css new file mode 100644 index 0000000..54b5932 --- /dev/null +++ b/backend/static/css/styles.css @@ -0,0 +1,215 @@ +/* Custom styles for Django Unfold admin */ +:root { + --primary-color: #4f46e5; + --primary-hover: #4338ca; +} + +.unfold-admin .button-primary { + background-color: var(--primary-color); +} + +.unfold-admin .button-primary:hover { + background-color: var(--primary-hover); +} + +/* Persian/RTL Support */ +html[lang="fa"], +html[dir="rtl"] { + direction: rtl; +} + +html[lang="fa"] body, +html[dir="rtl"] body { + font-family: "Vazir", "Tahoma", "Arial", sans-serif; + direction: rtl; + text-align: right; +} + +/* RTL adjustments for admin interface */ +html[lang="fa"] .unfold-admin, +html[dir="rtl"] .unfold-admin { + direction: rtl; +} + +html[lang="fa"] .unfold-admin .sidebar, +html[dir="rtl"] .unfold-admin .sidebar { + right: 0; + left: auto; +} + +html[lang="fa"] .unfold-admin .main-content, +html[dir="rtl"] .unfold-admin .main-content { + margin-right: 250px; + margin-left: 0; +} + +/* Persian number support */ +html[lang="fa"] .persian-numbers { + font-family: "Vazir", monospace; +} + +/* Custom styles for image previews */ +.image-preview { + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +/* Persian font loading */ +@font-face { + font-family: "Vazir"; + src: url("https://cdn.jsdelivr.net/gh/rastikerdar/vazir-font@v30.1.0/dist/Vazir-Regular.woff2") format("woff2"); + font-weight: normal; + font-style: normal; + font-display: swap; +} + +@font-face { + font-family: "Vazir"; + src: url("https://cdn.jsdelivr.net/gh/rastikerdar/vazir-font@v30.1.0/dist/Vazir-Bold.woff2") format("woff2"); + font-weight: bold; + font-style: normal; + font-display: swap; +} + + +/* --- MarkdownX / SimpleMDE Preview Overrides --- */ +/* Target the preview pane itself */ +.editor-preview-side, .editor-preview { + background-color: #ffffff; /* Ensure white background */ + padding: 15px; + border: 1px solid #e0e0e0; + border-radius: 5px; + box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.05); + overflow-x: auto; /* For wide content like code blocks */ + line-height: 1.6; /* Standard line height */ + color: #333; /* Default text color */ +} + +/* Reset common text elements */ +.editor-preview-side h1, .editor-preview h1, +.editor-preview-side h2, .editor-preview h2, +.editor-preview-side h3, .editor-preview h3, +.editor-preview-side h4, .editor-preview h4, +.editor-preview-side h5, .editor-preview h5, +.editor-preview-side h6, .editor-preview h6 { + font-family: inherit; /* Use default font */ + color: inherit; /* Use default color */ + margin-top: 1em; + margin-bottom: 0.5em; + line-height: 1.2; + font-weight: bold; +} + +.editor-preview-side h1, .editor-preview h1 { + font-size: 2em; +} +.editor-preview-side h2, .editor-preview h2 { + font-size: 1.5em; +} +.editor-preview-side h3, .editor-preview h3 { + font-size: 1.17em; +} +.editor-preview-side h4, .editor-preview h4 { + font-size: 1em; +} +.editor-preview-side h5, .editor-preview h5 { + font-size: 0.83em; +} +.editor-preview-side h6, .editor-preview h6 { + font-size: 0.67em; +} + +.editor-preview-side p, .editor-preview p { + margin-bottom: 1em; +} + +.editor-preview-side ul, .editor-preview ul, +.editor-preview-side ol, .editor-preview ol { + margin-left: 20px; + margin-bottom: 1em; +} + +.editor-preview-side li, .editor-preview li { + list-style: disc; +} + +.editor-preview-side > ol > li, .editor-preview > ol > li { + list-style: decimal; +} + +/* Code blocks */ +.editor-preview-side pre, .editor-preview pre { + background-color: #f4f4f4; + border: 1px solid #ddd; + border-radius: 4px; + padding: 10px; + overflow-x: auto; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; + font-size: 0.9em; + line-height: 1.4; +} + +.editor-preview-side code, .editor-preview code { + font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, Courier, monospace; + background-color: rgba(27, 31, 35, 0.05); + padding: 0.2em 0.4em; + border-radius: 3px; + font-size: 0.85em; +} + +pre code { + background: none !important; +} + +/* Tables */ +.editor-preview-side table, .editor-preview table { + width: 100%; + border-collapse: collapse; + margin-bottom: 1em; +} + +.editor-preview-side th, .editor-preview th, +.editor-preview-side td, .editor-preview td { + border: 1px solid #ccc; + padding: 8px; + text-align: left; +} + +.editor-preview-side th, .editor-preview th { + background-color: #f0f0f0; + font-weight: bold; +} + +/* Blockquotes */ +.editor-preview-side blockquote, .editor-preview blockquote { + border-left: 4px solid #ccc; + padding-left: 15px; + color: #666; + margin: 1em 0; +} + +/* Images */ +.editor-preview-side img, .editor-preview img { + max-width: 100%; + height: auto; + display: block; /* Prevent extra space below image */ + margin: 1em 0; +} + +/* Links */ +.editor-preview-side a, .editor-preview a { + color: #0366d6; /* Standard link color */ + text-decoration: underline; +} + +.editor-preview-side a:hover, .editor-preview a:hover { + text-decoration: none; +} + +/* Horizontal Rule */ +.editor-preview-side hr, .editor-preview hr { + border: 0; + height: 1px; + background: #eee; + margin: 1em 0; +} diff --git a/backend/static/img/logo.png b/backend/static/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c8011eaae77bdb5f2e6b960a339288200379a8b5 GIT binary patch literal 144230 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 z�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%0APJ1gJZ^0>q4Iw8q8DCpUV(jq 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 z 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 + await window.pushNotificationManager.setupPushNotifications(vapidPublicKey) diff --git a/backend/static/js/scripts.js b/backend/static/js/scripts.js new file mode 100644 index 0000000..5d671c4 --- /dev/null +++ b/backend/static/js/scripts.js @@ -0,0 +1,21 @@ +// Custom JavaScript for Django Unfold admin +document.addEventListener('DOMContentLoaded', function() { + // Add confirmation for hard delete actions + const hardDeleteButtons = document.querySelectorAll('[name="hard_delete"]'); + hardDeleteButtons.forEach(button => { + button.addEventListener('click', function(e) { + if (!confirm('Are you sure you want to permanently delete this item? This action cannot be undone.')) { + e.preventDefault(); + } + }); + }); + + // Auto-resize textareas + const textareas = document.querySelectorAll('textarea'); + textareas.forEach(textarea => { + textarea.addEventListener('input', function() { + this.style.height = 'auto'; + this.style.height = this.scrollHeight + 'px'; + }); + }); +}); diff --git a/backend/static/js/sw.js b/backend/static/js/sw.js new file mode 100644 index 0000000..8d94e03 --- /dev/null +++ b/backend/static/js/sw.js @@ -0,0 +1,95 @@ +// Service Worker for Push Notifications +const CACHE_NAME = "cs-association-v1" +const urlsToCache = [ + "/", + "/static/css/styles.css", + "/static/js/scripts.js", + "/static/images/icon-192x192.png", + "/static/images/icon-512x512.png", +] + +// Install event +self.addEventListener("install", (event) => { + event.waitUntil( + caches.open(CACHE_NAME).then((cache) => { + return cache.addAll(urlsToCache) + }), + ) +}) + +// Fetch event +self.addEventListener("fetch", (event) => { + event.respondWith( + caches.match(event.request).then((response) => { + // Return cached version or fetch from network + return response || fetch(event.request) + }), + ) +}) + +// Push event +self.addEventListener("push", (event) => { + if (event.data) { + const data = event.data.json() + const options = { + body: data.body, + icon: data.icon || "/static/images/icon-192x192.png", + badge: data.badge || "/static/images/badge-72x72.png", + data: data.data || {}, + actions: data.actions || [], + dir: data.dir || "ltr", + lang: data.lang || "en", + requireInteraction: data.data && data.data.priority === "urgent", + silent: false, + tag: data.data ? `${data.data.type}-${data.data.announcement_id || data.data.event_id}` : "default", + renotify: true, + vibrate: data.data && data.data.priority === "urgent" ? [200, 100, 200] : [100, 50, 100], + } + + event.waitUntil(self.registration.showNotification(data.title, options)) + } +}) + +// Notification click event +self.addEventListener("notificationclick", (event) => { + event.notification.close() + + if (event.action === "dismiss") { + return + } + + const data = event.notification.data + let url = "/" + + if (data && data.url) { + url = data.url + } + + event.waitUntil( + clients.matchAll({ type: "window" }).then((clientList) => { + // Check if there's already a window/tab open with the target URL + for (const client of clientList) { + if (client.url === url && "focus" in client) { + return client.focus() + } + } + + // If not, open a new window/tab + if (clients.openWindow) { + return clients.openWindow(url) + } + }), + ) +}) + +// Background sync (for offline functionality) +self.addEventListener("sync", (event) => { + if (event.tag === "background-sync") { + event.waitUntil(doBackgroundSync()) + } +}) + +function doBackgroundSync() { + // Implement background sync logic here + return Promise.resolve() +} diff --git a/backend/templates/emails/announcement_email.html b/backend/templates/emails/announcement_email.html new file mode 100644 index 0000000..7a59b21 --- /dev/null +++ b/backend/templates/emails/announcement_email.html @@ -0,0 +1,131 @@ + + + + + + {{ announcement.title }} + + + +

+
+ +

خبرنامه انجمن علوم کامپیوتر

+
+ +
+ {% if announcement.priority == 'urgent' %}فوری + {% elif announcement.priority == 'high' %}مهم + {% elif announcement.priority == 'normal' %}عادی + {% else %}کم اهمیت{% endif %} +
+ +
+ 📢 اطلاعیه + {% if announcement.announcement_type == 'general' %}عمومی + {% elif announcement.announcement_type == 'event' %}رویداد + {% elif announcement.announcement_type == 'academic' %}آکادمیک + {% elif announcement.announcement_type == 'urgent' %}فوری + {% else %}خبرنامه{% endif %} +
+ +

{{ announcement.title }}

+ +
+ {{ announcement.content_html|safe }} +
+ + +
+ + diff --git a/backend/templates/emails/event_announcement.html b/backend/templates/emails/event_announcement.html new file mode 100644 index 0000000..9a4b20e --- /dev/null +++ b/backend/templates/emails/event_announcement.html @@ -0,0 +1,19 @@ + + + +
+ + + + + + +
اطلاعیه
+

{{ user.get_full_name|default:'کاربر' }} عزیز،

+
{{ body_html|safe }}
+ +
© {% now 'Y' %} انجمن علمی
+
+ diff --git a/backend/templates/emails/event_invite_non_registered.html b/backend/templates/emails/event_invite_non_registered.html new file mode 100644 index 0000000..5918483 --- /dev/null +++ b/backend/templates/emails/event_invite_non_registered.html @@ -0,0 +1,65 @@ +{% load jalali %} + + + + + + + + +
+ + + + + + + + + + + + + +
+

انجمن علمی مهندسی کامپیوتر

+

دعوت به شرکت در رویداد

+
+

سلام {{ user.get_full_name|default:user.username }} عزیز،

+

+ شما هنوز در رویداد «{{ event.title }}» ثبت‌نام نکرده‌اید. اگر علاقه‌مند هستید، قبل از پایان مهلت ثبت‌نام می‌توانید از لینک زیر اقدام کنید. +

+ + + + + + + {% if event.location %} + + + + + {% endif %} +
تاریخ:{{ event.start_time|jdate:"%Y/%m/%d %H:%M"|fa_digits }}
مکان:{% if event.address %}{{ event.address }}{% else %}{{ event.event_type_display }}{% endif %}
+ + + +

+ اگر لینک بالا باز نشد، این آدرس را در مرورگر باز کنید: +

+

+ {{ event_url }} +

+ +

+ با احترام
تیم انجمن علمی مهندسی کامپیوتر +

+
+

این ایمیل به‌صورت خودکار ارسال شده است.

+
+
+ + diff --git a/backend/templates/emails/event_invite_non_registered.txt b/backend/templates/emails/event_invite_non_registered.txt new file mode 100644 index 0000000..8e95aeb --- /dev/null +++ b/backend/templates/emails/event_invite_non_registered.txt @@ -0,0 +1,11 @@ +{{ user.get_full_name|default:user.username }} عزیز، + +شما هنوز در رویداد «{{ event.title }}» ثبت‌نام نکرده‌اید. +تاریخ برگزاری: {{ start_time }} +مکان: {% if event.address %}{{ event.address }}{% else %}{{ event.event_type_display }}{% endif %} + +مشاهده و ثبت‌نام: +{{ event_url }} + +با احترام +تیم انجمن علمی مهندسی کامپیوتر diff --git a/backend/templates/emails/event_registration_cancellation.html b/backend/templates/emails/event_registration_cancellation.html new file mode 100644 index 0000000..9bb0447 --- /dev/null +++ b/backend/templates/emails/event_registration_cancellation.html @@ -0,0 +1,59 @@ + + + + + + لغو ثبت‌نام - انجمن علمی مهندسی کامپیوتر گیلان + + + +
+

انجمن علمی مهندسی کامپیوتر

+

لغو ثبت‌نام

+
+ +
+

سلام {{ user.get_full_name|default:'دانشجوی' }} گرامی،

+

ثبت‌نام شما در {{ event.title }} لغو شد.

+ + {% if event.start_time %} +
+

زمان رویداد: {{ event.start_time|date:"Y-m-d H:i" }}

+
+ {% endif %} + +

اگر این تغییر را شما انجام نداده‌اید، لطفاً با پشتیبانی تماس بگیرید.

+
+ + + + diff --git a/backend/templates/emails/event_registration_confirmation.html b/backend/templates/emails/event_registration_confirmation.html new file mode 100644 index 0000000..b48c52e --- /dev/null +++ b/backend/templates/emails/event_registration_confirmation.html @@ -0,0 +1,69 @@ +{% load jalali %} + + + + + + + + +
+ + + + + + + + + + {% if success_html %} + + + + {% endif %} + + + + +
+

تأیید ثبت‌نام شما

+
+

+ {{ user.get_full_name|default:'کاربر' }} عزیز، +

+

+ ثبت‌نام شما در رویداد {{ event.title }} تأیید شد. +

+ + + + + + + + + + +
تاریخ: + {{ event.start_time|jdate:"%Y/%m/%d %H:%M"|fa_digits }} + - + {{ event.end_time|jdate:"%Y/%m/%d %H:%M"|fa_digits }} +
محل برگزاری:{{ event.get_event_type_display }} | {{ event.address }}
+ + +
+
+ {{ success_html|safe }} +
+
+
© {% now 'Y' %} انجمن علمی مهندسی کامپیوتر شرق گیلان
+
+
+ + diff --git a/backend/templates/emails/event_reminder.html b/backend/templates/emails/event_reminder.html new file mode 100644 index 0000000..e79e822 --- /dev/null +++ b/backend/templates/emails/event_reminder.html @@ -0,0 +1,25 @@ +{% load jalali %} + + + +
+ + + + + + +
یادآوری رویداد
+

{{ user.get_full_name|default:'کاربر' }} عزیز، این یک یادآوری برای رویداد {{ event.title }} است.

+ + + + + +
تاریخ{{ event.start_time|jdate:"%Y/%m/%d %H:%M"|fa_digits }}
محل برگزاری{{ event.get_event_type_display }}{% if event.address %} | {{ event.address }}{% endif %}
+ +
© {% now 'Y' %} انجمن علمی
+
+ diff --git a/backend/templates/emails/newsletter_confirmation.html b/backend/templates/emails/newsletter_confirmation.html new file mode 100644 index 0000000..c6de788 --- /dev/null +++ b/backend/templates/emails/newsletter_confirmation.html @@ -0,0 +1,108 @@ + + + + + + تأیید اشتراک خبرنامه + + + +
+
+ +

انجمن علوم کامپیوتر

+
+ +

به خبرنامه ما خوش آمدید!

+ +

سلام،

+ +

از اشتراک شما در خبرنامه انجمن علوم کامپیوتر متشکریم. برای تکمیل فرآیند اشتراک، لطفاً آدرس ایمیل خود را با کلیک روی دکمه زیر تأیید کنید:

+ + + +

اگر دکمه کار نمی‌کند، می‌توانید این لینک را کپی کرده و در مرورگر خود باز کنید:

+

{{ confirmation_url }}

+ +

پس از تأیید، شما دریافت خواهید کرد:

+
    +
  • 📢 اطلاعیه‌های مهم
  • +
  • 🎓 اخبار آکادمیک
  • +
  • 🎉 اطلاع‌رسانی رویدادها
  • +
  • 💡 بینش‌های فناوری و فرصت‌ها
  • +
+ +

توجه: این لینک تأیید به دلایل امنیتی ظرف ۲۴ ساعت منقضی خواهد شد.

+ + +
+ + diff --git a/backend/templates/emails/password_reset_email.html b/backend/templates/emails/password_reset_email.html new file mode 100644 index 0000000..0766203 --- /dev/null +++ b/backend/templates/emails/password_reset_email.html @@ -0,0 +1,132 @@ + + + + + + بازنشانی رمز عبور - انجمن علمی مهندسی کامپیوتر گیلان + + + +
+

انجمن علمی مهندسی کامپیوتر

+

درخواست بازنشانی رمز عبور

+
+ +
+

سلام {{ user.get_full_name|default:'' }}!

+ +

ما درخواستی برای بازنشانی رمز عبور حساب کاربری شما دریافت کردیم. اگر شما این درخواست را داده‌اید، روی دکمه زیر کلیک کنید تا رمز عبور خود را بازنشانی کنید:

+ + + +

اگر دکمه کار نمی‌کند، می‌توانید این لینک را کپی کرده و در مرورگر خود قرار دهید:

+

{{ reset_url }}

+ +

این لینک بازنشانی رمز عبور به دلایل امنیتی پس از ۱ ساعت منقضی خواهد شد.

+ +

اگر شما درخواست بازنشانی رمز عبور نداده‌اید، لطفاً این ایمیل را نادیده بگیرید. رمز عبور شما بدون تغییر باقی خواهد ماند.

+ +

با احترام،
انجمن علمی مهندسی کامپیوتر دانشکده فنی و مهندسی شرق گیلان

+
+ + + + diff --git a/backend/templates/emails/skyroom_credentials.html b/backend/templates/emails/skyroom_credentials.html new file mode 100644 index 0000000..3223fe5 --- /dev/null +++ b/backend/templates/emails/skyroom_credentials.html @@ -0,0 +1,32 @@ +{% load jalali %} + + + +
+ + + + + + + + +
اطلاعات ورود اسکای‌روم
+

{{ user.get_full_name|default:'کاربر' }} عزیز،

+

برای شرکت در رویداد {{ event.title }} از اطلاعات زیر استفاده کنید:

+ + + + + + + + + +
تاریخ{{ event.start_time|jdate:"%Y/%m/%d %H:%M"|fa_digits }}
لینکورود به اسکای‌روم
نام کاربری{{ sky_username }}
رمز عبور{{ sky_password }}
+ +
© {% now 'Y' %} انجمن علمی
+
+ diff --git a/backend/templates/emails/verification_email.html b/backend/templates/emails/verification_email.html new file mode 100644 index 0000000..ee5686b --- /dev/null +++ b/backend/templates/emails/verification_email.html @@ -0,0 +1,61 @@ + + + + + + + +
+ + + + + + + + + + + + +
+

انجمن علمی مهندسی کامپیوتر

+

به جامعه ما خوش آمدید!

+
+

سلام {{ user.get_full_name|default:'دانشجوی' }} گرامی،

+

+ از ثبت‌نام شما متشکریم. برای تکمیل ثبت‌نام و فعال‌سازی حساب کاربری، لطفاً روی دکمه زیر کلیک کنید: +

+ + + +

+ اگر دکمه کار نمی‌کند، این لینک را در مرورگر خود باز کنید: +

+

+ {{ verification_url }} +

+ +

+ این لینک تا ۲۴ ساعت معتبر است. +

+ +

+ با احترام
انجمن علمی مهندسی کامپیوتر دانشکده فنی و مهندسی شرق گیلان +

+
+
© {% now 'Y' %} انجمن علمی مهندسی کامپیوتر شرق گیلان
+ +
این ایمیل به‌صورت خودکار ارسال شده است؛ لطفاً پاسخ ندهید.
+
+
+ + diff --git a/backend/templates/emails/verification_success.html b/backend/templates/emails/verification_success.html new file mode 100644 index 0000000..fb5d24b --- /dev/null +++ b/backend/templates/emails/verification_success.html @@ -0,0 +1,106 @@ + + + + + + تأیید ایمیل با موفقیت انجام شد + + + +
+

ایمیل شما با موفقیت تأیید شد

+

به جمع ما خوش آمدید!

+
+ +
+

سلام {{ user.get_full_name|default:'کاربر' }} عزیز،

+

+ آدرس ایمیل شما با موفقیت تأیید شد و حساب کاربری‌تان فعال است. + از این پس می‌توانید بدون محدودیت از امکانات سامانه استفاده کنید. +

+ + + +

+ اگر شما این اقدام را انجام نداده‌اید، لطفاً این ایمیل را نادیده بگیرید. +

+ +

با احترام
انجمن علمی مهندسی کامپیوتر دانشکده فنی و مهندسی شرق گیلان

+
+ + + + diff --git a/backend/templates/forms/admin_announcement.html b/backend/templates/forms/admin_announcement.html new file mode 100644 index 0000000..ea05526 --- /dev/null +++ b/backend/templates/forms/admin_announcement.html @@ -0,0 +1,26 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls unfold %} + +{% block extrahead %} + {{ block.super }} + + {{ form.media }} +{% endblock %} + +{% block content %} +
+ {% csrf_token %} + +
+ {% for field in form %} + {% include "unfold/helpers/field.html" with field=field %} + {% endfor %} +
+ +
+ {% component "unfold/components/button.html" with submit=1 %} + {% trans "Submit form" %} + {% endcomponent %} +
+
+{% endblock %} diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/test_events.py b/backend/tests/integration/test_events.py new file mode 100644 index 0000000..e94dca4 --- /dev/null +++ b/backend/tests/integration/test_events.py @@ -0,0 +1,540 @@ +import io +import json +import tempfile +import uuid +from datetime import timedelta +from types import SimpleNamespace + +from PIL import Image +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from django.utils import timezone + +from api.authentication import create_jwt_token +from api.schemas.events import ( + EventSchema, + EventGallerySchema, + EventListSchema, + RegistrationSchema, + PaymentAdminSchema, + EventAdminDetailSchema, +) +from api.views.events import list_events +from events.models import Event, Registration +from gallery.models import Gallery +from payments.models import DiscountCode +from users.models import Major, University, User + +MEDIA_ROOT = tempfile.mkdtemp() + + +@override_settings(MEDIA_ROOT=MEDIA_ROOT) +class EventsAPIIntegrationTests(TestCase): + password = "TestPass123!" + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user( + username="event_user", + email="event.user@example.com", + password=cls.password, + ) + cls.user.is_email_verified = True + cls.user.save(update_fields=["is_email_verified"]) + + cls.staff = User.objects.create_user( + username="event_staff", + email="event.staff@example.com", + password=cls.password, + is_staff=True, + ) + cls.staff.is_email_verified = True + cls.staff.save(update_fields=["is_email_verified"]) + cls.major, _ = Major.objects.get_or_create(code="CS", defaults={"name": "Computer Science"}) + cls.university, _ = University.objects.get_or_create(code="UT", defaults={"name": "University of Tehran"}) + cls.user.major = cls.major + cls.user.university = cls.university + cls.user.save(update_fields=["major", "university"]) + cls.staff.major = cls.major + cls.staff.university = cls.university + cls.staff.save(update_fields=["major", "university"]) + + def setUp(self): + super().setUp() + self.token = create_jwt_token(self.user) + self.staff_token = create_jwt_token(self.staff) + + self.event = self._create_event( + title="Integration Event", + description="Integration description.", + status=Event.StatusChoices.PUBLISHED, + price=0, + ) + self.other_event = self._create_event( + title="Other Published", + description="Searchable", + status=Event.StatusChoices.PUBLISHED, + price=0, + ) + + def _auth_headers(self, token): + return {"HTTP_AUTHORIZATION": f"Bearer {token}"} + + def _create_event(self, **overrides): + now = timezone.now() + defaults = { + "title": "Event Title", + "description": "Description", + "start_time": now, + "end_time": now + timedelta(hours=2), + "registration_start_date": now - timedelta(days=1), + "registration_end_date": now + timedelta(days=5), + "slug": f"event-{uuid.uuid4().hex[:6]}", + "location": "Campus", + "online_link": "https://meet.example.com", + "price": 0, + "capacity": 10, + "status": Event.StatusChoices.PUBLISHED, + } + defaults.update(overrides) + return Event.objects.create(**defaults) + + def _create_gallery_image(self): + buffer = io.BytesIO() + Image.new("RGB", (10, 10), color="blue").save(buffer, format="PNG") + buffer.seek(0) + file = SimpleUploadedFile("gallery.png", buffer.read(), content_type="image/png") + return Gallery.objects.create( + title="Gallery image", + description="desc", + image=file, + uploaded_by=self.user, + ) + + def _create_paid_event(self): + return self._create_event(price=30000, capacity=5) + + def _create_registration(self, event, user, status=Registration.StatusChoices.PENDING): + return Registration.objects.create(event=event, user=user, status=status, final_price=event.price) + + # Basic event endpoints ------------------------------------------------ + + def test_list_events_filters_and_search(self): + # Act + response = self.client.get("/api/events/", {"status": "published", "search": "Searchable"}) + data = response.json() + + # Assert + self.assertEqual(response.status_code, 200) + self.assertTrue(any(item["id"] == self.other_event.id for item in data)) + + def test_get_event_by_id_and_slug(self): + response_id = self.client.get(f"/api/events/{self.event.id}") + response_slug = self.client.get(f"/api/events/slug/{self.event.slug}") + + self.assertEqual(response_id.status_code, 200) + self.assertEqual(response_slug.status_code, 200) + self.assertEqual(response_id.json()["id"], self.event.id) + self.assertEqual(response_slug.json()["slug"], self.event.slug) + + def test_create_update_and_delete_event(self): + payload = { + "title": "New Event", + "description": "Desc", + "start_time": (timezone.now() + timedelta(days=1)).isoformat(), + "end_time": (timezone.now() + timedelta(days=1, hours=1)).isoformat(), + "event_type": Event.TypeChoices.ON_SITE, + "status": Event.StatusChoices.DRAFT, + "price": 5000, + } + created = self.client.post( + "/api/events/", + data=json.dumps(payload), + content_type="application/json", + ) + self.assertEqual(created.status_code, 200) + event_id = created.json()["id"] + + updated = self.client.put( + f"/api/events/{event_id}", + data=json.dumps({"title": "Updated Event"}), + content_type="application/json", + ) + self.assertEqual(updated.status_code, 200) + self.assertEqual(updated.json()["title"], "Updated Event") + + deleted = self.client.delete(f"/api/events/{event_id}") + self.assertEqual(deleted.status_code, 200) + + def test_admin_detail_and_registration_list_requires_staff(self): + staff_headers = self._auth_headers(self.staff_token) + user_headers = self._auth_headers(self.token) + + _ = self._create_registration(self.event, self.user, status=Registration.StatusChoices.CONFIRMED) + + # Non staff forbidden + list_resp = self.client.get(f"/api/events/{self.event.id}/admin-registrations", **user_headers) + self.assertEqual(list_resp.status_code, 403) + + # Staff allowed + list_resp = self.client.get(f"/api/events/{self.event.id}/admin-registrations", **staff_headers) + detail_resp = self.client.get(f"/api/events/{self.event.id}/admin-detail", **staff_headers) + self.assertEqual(list_resp.status_code, 200) + self.assertEqual(detail_resp.status_code, 200) + + def test_list_events_filters_by_event_type_and_search(self): + event = self._create_event( + title="Special Search", + description="Unique discovery", + event_type=Event.TypeChoices.ONLINE, + status=Event.StatusChoices.PUBLISHED, + ) + response = self.client.get( + "/api/events/", + { + "event_type": Event.TypeChoices.ONLINE, + "search": "Unique discovery", + }, + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(any(item["id"] == event.id for item in response.json())) + + def test_list_events_handles_comma_status_parameter(self): + event = self._create_event( + title="Comma Event", + status=Event.StatusChoices.PUBLISHED, + ) + results = list_events( + None, + status=f"{Event.StatusChoices.PUBLISHED},{Event.StatusChoices.DRAFT}", + event_type=None, + search=None, + limit=10, + offset=0, + ) + self.assertIn(event, list(results)) + + def test_create_event_attaches_gallery_images(self): + gallery = self._create_gallery_image() + payload = { + "title": "Gallery Event", + "description": "Gallery desc", + "start_time": (timezone.now() + timedelta(days=1)).isoformat(), + "end_time": (timezone.now() + timedelta(days=1, hours=1)).isoformat(), + "event_type": Event.TypeChoices.ON_SITE, + "status": Event.StatusChoices.DRAFT, + "price": 5000, + "gallery_image_ids": [gallery.id], + } + response = self.client.post( + "/api/events/", + data=json.dumps(payload), + content_type="application/json", + ) + body = response.json() + self.assertEqual(response.status_code, 200) + self.assertTrue(body["gallery_images"]) + + updated = self.client.put( + f"/api/events/{body['id']}", + data=json.dumps( + { + "title": "Gallery Event Updated", + "gallery_image_ids": [gallery.id], + } + ), + content_type="application/json", + ) + self.assertEqual(updated.status_code, 200) + self.assertEqual(updated.json()["slug"], "gallery-event-updated") + self.assertTrue(updated.json()["gallery_images"]) + + def test_admin_registration_filters_include_university_major_and_search(self): + event = self.event + self._create_registration(event, self.user, status=Registration.StatusChoices.CONFIRMED) + headers = self._auth_headers(self.staff_token) + response = self.client.get( + f"/api/events/{event.id}/admin-registrations", + { + "university": self.user.university.code, + "major": self.user.major.code, + "search": self.user.username, + "status": [Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.PENDING], + }, + **headers, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["count"], 1) + + def test_register_before_start_and_after_end_dates_fail(self): + future_event = self._create_event(registration_start_date=timezone.now() + timedelta(days=1)) + future_response = self.client.post( + f"/api/events/{future_event.id}/register", + HTTP_AUTHORIZATION=f"Bearer {self.token}", + ) + self.assertEqual(future_response.status_code, 400) + + closed_event = self._create_event(registration_end_date=timezone.now() - timedelta(hours=1)) + closed_response = self.client.post( + f"/api/events/{closed_event.id}/register", + HTTP_AUTHORIZATION=f"Bearer {self.token}", + ) + self.assertEqual(closed_response.status_code, 400) + + def test_register_recreates_after_cancelled_registration(self): + event = self._create_event(price=0) + Registration.objects.create( + event=event, + user=self.user, + status=Registration.StatusChoices.CANCELLED, + final_price=0, + ) + + response = self.client.post( + f"/api/events/{event.id}/register", + HTTP_AUTHORIZATION=f"Bearer {self.token}", + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], Registration.StatusChoices.CONFIRMED) + + def test_register_updates_final_price_when_none(self): + event = self._create_paid_event() + registration = Registration.objects.create( + event=event, + user=self.user, + status=Registration.StatusChoices.PENDING, + final_price=None, + ) + response = self.client.post( + f"/api/events/{event.id}/register", + HTTP_AUTHORIZATION=f"Bearer {self.token}", + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["final_price"], event.price) + + def _create_discount_code(self, event): + code = DiscountCode.objects.create( + code=f"CODE-{uuid.uuid4().hex[:4]}", + value=50, + type=DiscountCode.Type.PERCENT, + is_active=True, + ) + code.applicable_events.add(event) + return code + + def test_register_for_event_with_free_price_confirms(self): + event = self._create_event(price=0) + response = self.client.post( + f"/api/events/{event.id}/register", + HTTP_AUTHORIZATION=f"Bearer {self.token}", + content_type="application/json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["status"], Registration.StatusChoices.CONFIRMED) + + def test_register_for_event_with_discount_updates_final_price(self): + event = self._create_paid_event() + code = self._create_discount_code(event) + response = self.client.post( + f"/api/events/{event.id}/register", + data=json.dumps({"discount_code": code.code}), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {self.token}", + ) + + result = response.json() + self.assertEqual(response.status_code, 200) + self.assertEqual(result["discount_code"], code.code) + self.assertEqual(result["discount_amount"], event.price // 2) + self.assertEqual(result["final_price"], event.price // 2) + + def test_register_fails_when_capacity_full(self): + event = self._create_event(capacity=1) + other = self._create_event_user("other_user", "other@example.com") + Registration.objects.create( + event=event, + user=other, + status=Registration.StatusChoices.CONFIRMED, + final_price=0, + ) + + response = self.client.post( + f"/api/events/{event.id}/register", + HTTP_AUTHORIZATION=f"Bearer {self.token}", + content_type="application/json", + ) + + self.assertEqual(response.status_code, 400) + + def _create_event_user(self, username, email): + user = User.objects.create_user(username=username, email=email, password=self.password) + user.is_email_verified = True + user.save(update_fields=["is_email_verified"]) + user.major = self.user.major + user.university = self.user.university + user.save(update_fields=["major", "university"]) + return user + + def test_register_rejects_duplicate_confirmed(self): + event = self._create_event(price=0) + Registration.objects.create( + event=event, + user=self.user, + status=Registration.StatusChoices.CONFIRMED, + final_price=0, + ) + + response = self.client.post( + f"/api/events/{event.id}/register", + HTTP_AUTHORIZATION=f"Bearer {self.token}", + content_type="application/json", + ) + + self.assertEqual(response.status_code, 400) + + def test_registration_status_update_and_cancel(self): + event = self._create_event(price=0) + registration = self._create_registration(event, self.user) + + update = self.client.put( + f"/api/events/registrations/{registration.id}", + data=json.dumps({"status": Registration.StatusChoices.ATTENDED}), + content_type="application/json", + HTTP_AUTHORIZATION=f"Bearer {self.token}", + ) + self.assertEqual(update.status_code, 200) + self.assertEqual(update.json()["status"], Registration.StatusChoices.ATTENDED) + + cancel = self.client.delete( + f"/api/events/registrations/{registration.id}", + HTTP_AUTHORIZATION=f"Bearer {self.token}", + ) + self.assertEqual(cancel.status_code, 200) + self.assertEqual(cancel.json()["message"], "ثبت‌نام شما لغو شد :(") + + def test_verify_registration_and_my_registrations(self): + event = self._create_event(price=0) + registration = self._create_registration(event, self.user, status=Registration.StatusChoices.CONFIRMED) + + verify = self.client.get( + f"/api/events/registerations/verify/{registration.ticket_id}", + HTTP_AUTHORIZATION=f"Bearer {self.token}", + ) + self.assertEqual(verify.status_code, 200) + self.assertEqual(verify.json()["ticket_id"], str(registration.ticket_id)) + + my_regs = self.client.get( + "/api/events/my-registrations", + HTTP_AUTHORIZATION=f"Bearer {self.token}", + ) + self.assertEqual(my_regs.status_code, 200) + self.assertGreater(len(my_regs.json()), 0) + + status_resp = self.client.get( + f"/api/events/{event.id}/is-registered", + HTTP_AUTHORIZATION=f"Bearer {self.token}", + ) + self.assertEqual(status_resp.status_code, 200) + self.assertTrue(status_resp.json()["is_registered"]) + + def test_list_event_registrations(self): + event = self.event + self._create_registration(event, self.user) + + response = self.client.get(f"/api/events/{event.id}/registrations") + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()) + + def test_list_event_registrations_admin_filters(self): + event = self.event + self._create_registration(event, self.user, status=Registration.StatusChoices.PENDING) + headers = self._auth_headers(self.staff_token) + response = self.client.get( + f"/api/events/{event.id}/admin-registrations", + {"status": [Registration.StatusChoices.PENDING]}, + **headers, + ) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["count"], 1) + + +class EventSchemasIntegrationTests(TestCase): + password = "SchemaPass!123" + + def setUp(self): + self.user = User.objects.create_user( + username="schema_user", + email="schema.user@example.com", + password=self.password, + ) + self.user.is_email_verified = True + self.user.save(update_fields=["is_email_verified"]) + + self.event = Event.objects.create( + title="Schema Event", + description="**bold**", + start_time=timezone.now(), + end_time=timezone.now() + timedelta(hours=1), + registration_start_date=timezone.now() - timedelta(days=1), + registration_end_date=timezone.now() + timedelta(days=1), + price=1000, + slug="schema-event", + ) + Registration.objects.create( + event=self.event, + user=self.user, + status=Registration.StatusChoices.CONFIRMED, + final_price=0, + ) + Registration.objects.create( + event=self.event, + user=self.user, + status=Registration.StatusChoices.ATTENDED, + final_price=0, + ) + + def _mock_request(self): + return SimpleNamespace(build_absolute_uri=lambda path: f"https://test{path}") + + def test_gallery_schema_returns_full_url(self): + obj = SimpleNamespace(image=SimpleNamespace(url="/media/gallery.png")) + result = EventGallerySchema.resolve_absolute_image_url(obj, {"request": self._mock_request()}) + self.assertEqual(result, "https://test/media/gallery.png") + + def test_event_schema_resolvers(self): + context = {"request": self._mock_request()} + event_obj = SimpleNamespace(featured_image=SimpleNamespace(url="/media/feat.png"), registrations=self.event.registrations) + self.assertEqual(EventSchema.resolve_absolute_featured_image_url(event_obj, context), "https://test/media/feat.png") + self.assertEqual(EventSchema.resolve_registration_count(self.event), 2) + self.assertIn("

", EventSchema.resolve_description_html(self.event)) + + def test_event_list_schema_resolvers(self): + obj = SimpleNamespace(featured_image=SimpleNamespace(url="/media/feat.png"), registrations=self.event.registrations) + context = {"request": self._mock_request()} + self.assertEqual(EventListSchema.resolve_absolute_featured_image_url(obj, context), "https://test/media/feat.png") + self.assertEqual(EventListSchema.resolve_registration_count(self.event), 2) + + def test_registration_schema_resolves_discount_code(self): + discount = DiscountCode.objects.create(code="SCHEMA", type=DiscountCode.Type.FIXED, value=100, is_active=True) + discount.applicable_events.add(self.event) + registration = Registration.objects.create( + event=self.event, + user=self.user, + status=Registration.StatusChoices.CONFIRMED, + final_price=900, + discount_code=discount, + ) + self.assertEqual(RegistrationSchema.resolve_discount_code(registration), discount.code) + + def test_payment_admin_schema_normalizes_discount_code(self): + self.assertIsNone(PaymentAdminSchema.normalize_discount_code(None)) + self.assertEqual(PaymentAdminSchema.normalize_discount_code("123"), "123") + self.assertEqual(PaymentAdminSchema.normalize_discount_code(SimpleNamespace(code="ABC")), "ABC") + + def test_event_admin_detail_resolves_registrations(self): + registrations = EventAdminDetailSchema.resolve_registrations(self.event) + self.assertTrue(list(registrations)) + # TODO registration-related tests diff --git a/backend/tests/integration/test_payments.py b/backend/tests/integration/test_payments.py new file mode 100644 index 0000000..af09a98 --- /dev/null +++ b/backend/tests/integration/test_payments.py @@ -0,0 +1,282 @@ +import json +from datetime import timedelta +from unittest import mock + +from django.test import TestCase, override_settings +from django.utils import timezone + +from api.authentication import create_jwt_token +from events.models import Event, Registration +from payments.models import Payment, DiscountCode +from users.models import User + + +@override_settings( + ZARINPAL_MERCHANT_ID="MID", + ZARINPAL_REQUEST_URL="https://zarinpal/request", + ZARINPAL_STARTPAY="https://zarinpal/start/", + ZARINPAL_VERIFY_URL="https://zarinpal/verify", + ZARINPAL_CALLBACK_URL="https://frontend/callback", +) +class PaymentsAPIIntegrationTests(TestCase): + password = "PaymentPass!123" + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user( + username="pay_user", + email="pay.user@example.com", + password=cls.password, + ) + cls.user.is_email_verified = True + cls.user.save(update_fields=["is_email_verified"]) + + def setUp(self): + super().setUp() + self.event = Event.objects.create( + title="Pay Event", + description="Payment event", + start_time=timezone.now(), + end_time=timezone.now() + timedelta(hours=2), + registration_start_date=timezone.now() - timedelta(days=1), + registration_end_date=timezone.now() + timedelta(days=1), + slug="pay-event", + price=50000, + capacity=10, + status=Event.StatusChoices.PUBLISHED, + ) + self.token = create_jwt_token(self.user) + + def _headers(self): + return {"HTTP_AUTHORIZATION": f"Bearer {self.token}"} + + def _create_paid_event(self): + return Event.objects.create( + title="Paid Event", + description="Paid", + start_time=timezone.now(), + end_time=timezone.now() + timedelta(hours=1), + registration_start_date=timezone.now() - timedelta(days=1), + registration_end_date=timezone.now() + timedelta(days=2), + slug=f"paid-{timezone.now().timestamp()}", + price=20000, + capacity=5, + status=Event.StatusChoices.PUBLISHED, + ) + + def _create_discount_code(self, event): + code = DiscountCode.objects.create( + code="DISC50", + value=50, + is_active=True, + ) + code.applicable_events.add(event) + return code + + def test_create_payment_for_free_event(self): + free = Event.objects.create( + title="Free", + description="Zero", + start_time=timezone.now(), + end_time=timezone.now() + timedelta(hours=1), + registration_start_date=timezone.now() - timedelta(days=1), + registration_end_date=timezone.now() + timedelta(days=1), + slug="free-event", + price=0, + capacity=10, + status=Event.StatusChoices.PUBLISHED, + ) + response = self.client.post( + "/api/payments/create", + data=json.dumps( + { + "event_id": free.id, + "description": "Free registration", + } + ), + content_type="application/json", + **self._headers(), + ) + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["amount"], 0) + self.assertIsNone(data["start_pay_url"]) + + @mock.patch("api.views.payments.requests.post") + def test_create_payment_with_discount(self, mock_post): + mock_response = mock.Mock() + mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}} + mock_post.return_value = mock_response + + code = self._create_discount_code(self.event) + response = self.client.post( + "/api/payments/create", + data=json.dumps( + { + "event_id": self.event.id, + "description": "Pay with discount", + "discount_code": code.code, + } + ), + content_type="application/json", + **self._headers(), + ) + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["discount_amount"], self.event.price // 2) + self.assertEqual(payload["amount"], self.event.price // 2) + self.assertIn("start_pay_url", payload) + payment = Payment.objects.get(user=self.user, event=self.event) + self.assertEqual(payment.discount_code, code) + + @mock.patch("api.views.payments.requests.post") + def test_callback_success_marks_paid(self, mock_post): + payment = Payment.objects.create( + user=self.user, + event=self.event, + base_amount=self.event.price, + amount=self.event.price, + status=Payment.OrderStatusChoices.PENDING, + authority="AUTH123", + ) + mock_resp = mock.Mock() + mock_resp.json.return_value = {"data": {"code": 100, "ref_id": "REF", "card_pan": "123", "card_hash": "ABC"}} + mock_post.return_value = mock_resp + + response = self.client.get( + "/api/payments/callback", + {"Authority": "AUTH123", "Status": "OK"}, + ) + payment.refresh_from_db() + self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID) + self.assertTrue("status=success" in response.url) + + @mock.patch("api.views.payments.requests.post") + def test_callback_failure_redirects_failed(self, mock_post): + payment = Payment.objects.create( + user=self.user, + event=self.event, + base_amount=self.event.price, + amount=self.event.price, + status=Payment.OrderStatusChoices.PENDING, + authority="AUTH456", + ) + mock_resp = mock.Mock() + mock_resp.json.return_value = {"data": {"code": 101, "ref_id": "REF"}} + mock_post.return_value = mock_resp + + response = self.client.get( + "/api/payments/callback", + {"Authority": "AUTH456", "Status": "OK"}, + ) + + payment.refresh_from_db() + self.assertEqual(payment.status, Payment.OrderStatusChoices.PAID) + self.assertTrue("status=success" in response.url) + + def test_callback_missing_authority_returns_error(self): + response = self.client.get("/api/payments/callback", {"Status": "OK"}) + self.assertEqual(response.status_code, 400) + + def test_callback_not_ok_cancels(self): + payment = Payment.objects.create( + user=self.user, + event=self.event, + base_amount=self.event.price, + amount=self.event.price, + status=Payment.OrderStatusChoices.PENDING, + authority="AUTH789", + ) + response = self.client.get( + "/api/payments/callback", + {"Authority": "AUTH789", "Status": "NOK"}, + ) + payment.refresh_from_db() + self.assertEqual(payment.status, Payment.OrderStatusChoices.CANCELED) + self.assertIn("status=failed", response.url) + + @mock.patch("api.views.payments.requests.post", side_effect=RuntimeError("down")) + def test_create_payment_gateway_failure(self, mock_post): + response = self.client.post( + "/api/payments/create", + data=json.dumps( + { + "event_id": self.event.id, + "description": "Gateway fail", + } + ), + content_type="application/json", + **self._headers(), + ) + self.assertEqual(response.status_code, 502) + self.assertFalse(Payment.objects.filter(user=self.user).exists()) + + def test_create_payment_when_already_paid(self): + Payment.objects.create( + user=self.user, + event=self.event, + base_amount=self.event.price, + amount=self.event.price, + status=Payment.OrderStatusChoices.PAID, + ) + response = self.client.post( + "/api/payments/create", + data=json.dumps({"event_id": self.event.id, "description": "Duplicate"}), + content_type="application/json", + **self._headers(), + ) + self.assertEqual(response.status_code, 400) + + @mock.patch("api.views.payments.requests.post") + def test_registration_final_price_none_updates(self, mock_post): + registration = Registration.objects.create( + event=self.event, + user=self.user, + status=Registration.StatusChoices.PENDING, + final_price=None, + ) + mock_response = mock.Mock() + mock_response.json.return_value = {"data": {"code": 100, "authority": "AUTH"}} + mock_post.return_value = mock_response + response = self.client.post( + "/api/payments/create", + data=json.dumps({"event_id": self.event.id, "description": "Update"}), + content_type="application/json", + **self._headers(), + ) + self.assertEqual(response.status_code, 200) + registration.refresh_from_db() + if registration.final_price is None: + self.fail("final_price should be populated") + + def test_coupon_check_success_and_errors(self): + code = DiscountCode.objects.create(code="PAYCO", value=20, is_active=True, type=DiscountCode.Type.PERCENT) + code.applicable_events.add(self.event) + + # missing code + missing = self.client.post( + "/api/payments/coupon/check", + data=json.dumps({"event_id": self.event.id}), + content_type="application/json", + **self._headers(), + ) + self.assertEqual(missing.status_code, 422) + + # invalid code + invalid = self.client.post( + "/api/payments/coupon/check", + data=json.dumps({"event_id": self.event.id, "code": "INVALID"}), + content_type="application/json", + **self._headers(), + ) + self.assertEqual(invalid.status_code, 404) + + success = self.client.post( + "/api/payments/coupon/check", + data=json.dumps({"event_id": self.event.id, "code": code.code}), + content_type="application/json", + **self._headers(), + ) + self.assertEqual(success.status_code, 200) + self.assertIn("final_price", success.json()) diff --git a/backend/tests/integration/test_users.py b/backend/tests/integration/test_users.py new file mode 100644 index 0000000..3ba6d2f --- /dev/null +++ b/backend/tests/integration/test_users.py @@ -0,0 +1,724 @@ +import json +import shutil +import tempfile +import uuid +from datetime import timedelta +from unittest import mock + +from django.conf import settings +from django.core.files.uploadedfile import SimpleUploadedFile +from django.test import TestCase, override_settings +from django.utils import timezone + +import jwt + +from users.models import User, Major, University + + +class UsersAPIIntegrationTests(TestCase): + password = "Sup3rSecure!123" + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.major_cs, _ = Major.objects.get_or_create( + code="CS", defaults={"name": "Computer Science"} + ) + cls.major_gil, _ = Major.objects.get_or_create( + code="GIL_CS", defaults={"name": "Gilan Computer Science"} + ) + cls.university_ut, _ = University.objects.get_or_create( + code="UT", defaults={"name": "University of Tehran"} + ) + cls.university_gilan, _ = University.objects.get_or_create( + code="GILAN", defaults={"name": "Gilan University"} + ) + + def setUp(self): + super().setUp() + patchers = [ + mock.patch("users.tasks.send_verification_email.delay"), + mock.patch("users.signals.send_verification_email.delay"), + mock.patch("users.tasks.send_password_reset_email.delay"), + ] + ( + self.mock_send_verification_task, + self.mock_signal_verification_task, + self.mock_password_reset_task, + ) = [patcher.start() for patcher in patchers] + for patcher in patchers: + self.addCleanup(patcher.stop) + + # Helper utilities ----------------------------------------------------- + + def _numeric_student_id(self) -> str: + return str(uuid.uuid4().int)[-10:] + + def _resolve_major(self, value): + if value is None: + return None + if isinstance(value, Major): + return value + return Major.objects.filter(code=value).first() + + def _resolve_university(self, value): + if value is None: + return None + if isinstance(value, University): + return value + return University.objects.filter(code=value).first() + + def _create_user(self, **overrides) -> User: + unique = uuid.uuid4().hex[:8] + defaults = { + "username": f"user_{unique}", + "email": f"{unique}@example.com", + "student_id": self._numeric_student_id(), + "first_name": "Test", + "last_name": "User", + "year_of_study": 2, + "major": self.major_cs, + "university": self.university_ut, + } + defaults.update(overrides) + if isinstance(defaults.get("major"), str): + defaults["major"] = self._resolve_major(defaults["major"]) + if isinstance(defaults.get("university"), str): + defaults["university"] = self._resolve_university(defaults["university"]) + password = defaults.pop("password", self.password) + return User.objects.create_user(password=password, **defaults) + + def _auth_headers(self, token: str) -> dict: + return {"HTTP_AUTHORIZATION": f"Bearer {token}"} + + def _login_and_get_tokens(self, user: User, password: str | None = None) -> dict: + response = self.client.post( + "/api/auth/login", + data=json.dumps({"email": user.email, "password": password or self.password}), + content_type="application/json", + ) + self.assertEqual(response.status_code, 200) + return response.json() + + def _refresh_token_value(self, user: User | None = None, **overrides) -> str: + now = timezone.now() + payload = { + "type": "refresh", + "exp": now + timedelta(minutes=5), + "iat": now, + } + if user is not None: + payload["user_id"] = user.id + payload.update(overrides) + return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM) + + # Registration --------------------------------------------------------- + + def test_register_creates_user_and_enqueues_signal(self): + # Arrange + payload = { + "username": "integration_user", + "email": "integration@example.com", + "password": "RegisterPass!9", + "student_id": "2023123456", + "first_name": "Integration", + "last_name": "Tester", + "university": self.university_ut.code, + "major": self.major_cs.code, + "year_of_study": 3, + } + + # Act + response = self.client.post( + "/api/auth/register", data=json.dumps(payload), content_type="application/json" + ) + + # Assert + self.assertEqual(response.status_code, 201) + self.assertTrue(User.objects.filter(email=payload["email"]).exists()) + self.assertTrue(self.mock_signal_verification_task.called) + + def test_register_rejects_short_student_id(self): + # Arrange + payload = { + "username": "short_id", + "email": "short@example.com", + "password": "RegisterPass!9", + "student_id": "123456789", # 9 digits + } + + # Act + response = self.client.post( + "/api/auth/register", data=json.dumps(payload), content_type="application/json" + ) + + # Assert + self.assertEqual(response.status_code, 400) + + def test_register_rejects_duplicate_username(self): + # Arrange + existing = self._create_user(username="duplicate") + payload = { + "username": existing.username, + "email": "someone@example.com", + "password": "RegisterPass!9", + } + + # Act + response = self.client.post( + "/api/auth/register", data=json.dumps(payload), content_type="application/json" + ) + + # Assert + self.assertEqual(response.status_code, 400) + + def test_register_rejects_duplicate_email(self): + # Arrange + existing = self._create_user(email="duplicate@example.com") + payload = { + "username": "newuser", + "email": existing.email, + "password": "RegisterPass!9", + } + + # Act + response = self.client.post( + "/api/auth/register", data=json.dumps(payload), content_type="application/json" + ) + + # Assert + self.assertEqual(response.status_code, 400) + + def test_register_rejects_duplicate_student_id_in_same_university(self): + # Arrange + student_id = "2023012345" + self._create_user(student_id=student_id, university=self.university_gilan) + payload = { + "username": "dupstudent", + "email": "dupstudent@example.com", + "password": "RegisterPass!9", + "student_id": student_id, + "university": self.university_gilan.code, + } + + # Act + response = self.client.post( + "/api/auth/register", data=json.dumps(payload), content_type="application/json" + ) + + # Assert + self.assertEqual(response.status_code, 400) + + # Login & Refresh ------------------------------------------------------ + + def test_login_returns_tokens_for_verified_user(self): + # Arrange + user = self._create_user() + user.is_email_verified = True + user.save(update_fields=["is_email_verified"]) + + # Act + response = self.client.post( + "/api/auth/login", + data=json.dumps({"email": user.email, "password": self.password}), + content_type="application/json", + ) + + # Assert + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertIn("access_token", body) + self.assertIn("refresh_token", body) + + def test_login_rejects_unverified_user(self): + # Arrange + user = self._create_user() + + # Act + response = self.client.post( + "/api/auth/login", + data=json.dumps({"email": user.email, "password": self.password}), + content_type="application/json", + ) + + # Assert + self.assertEqual(response.status_code, 401) + + def test_login_rejects_inactive_user(self): + # Arrange + user = self._create_user(is_email_verified=True, is_active=False) + + # Act + response = self.client.post( + "/api/auth/login", + data=json.dumps({"email": user.email, "password": self.password}), + content_type="application/json", + ) + + # Assert + self.assertEqual(response.status_code, 401) + + def test_refresh_returns_tokens(self): + # Arrange + user = self._create_user(is_email_verified=True) + tokens = self._login_and_get_tokens(user) + + # Act + response = self.client.post( + "/api/auth/refresh", + data=json.dumps({"refresh_token": tokens["refresh_token"]}), + content_type="application/json", + ) + + # Assert + self.assertEqual(response.status_code, 200) + refreshed = response.json() + self.assertIn("access_token", refreshed) + self.assertIn("refresh_token", refreshed) + + def test_refresh_rejects_non_refresh_token(self): + # Arrange + user = self._create_user(is_email_verified=True) + tokens = self._login_and_get_tokens(user) + + # Act + response = self.client.post( + "/api/auth/refresh", + data=json.dumps({"refresh_token": tokens["access_token"]}), + content_type="application/json", + ) + + # Assert + self.assertEqual(response.status_code, 401) + + def test_refresh_rejects_missing_user_id(self): + # Arrange + token = self._refresh_token_value() + + # Act + response = self.client.post( + "/api/auth/refresh", + data=json.dumps({"refresh_token": token}), + content_type="application/json", + ) + + # Assert + self.assertEqual(response.status_code, 401) + + def test_refresh_rejects_unverified_user(self): + # Arrange + user = self._create_user() + token = self._refresh_token_value(user=user) + + # Act + response = self.client.post( + "/api/auth/refresh", + data=json.dumps({"refresh_token": token}), + content_type="application/json", + ) + + # Assert + self.assertEqual(response.status_code, 401) + + def test_refresh_rejects_inactive_user(self): + # Arrange + user = self._create_user(is_email_verified=True, is_active=False) + token = self._refresh_token_value(user=user) + + # Act + response = self.client.post( + "/api/auth/refresh", + data=json.dumps({"refresh_token": token}), + content_type="application/json", + ) + + # Assert + self.assertEqual(response.status_code, 401) + + def test_refresh_rejects_expired_token(self): + # Arrange + user = self._create_user(is_email_verified=True) + token = self._refresh_token_value( + user=user, + exp=timezone.now() - timedelta(minutes=1), + ) + + # Act + response = self.client.post( + "/api/auth/refresh", + data=json.dumps({"refresh_token": token}), + content_type="application/json", + ) + + # Assert + self.assertEqual(response.status_code, 401) + + def test_refresh_rejects_invalid_token_string(self): + # Arrange + token = "not-a-valid-token" + + # Act + response = self.client.post( + "/api/auth/refresh", + data=json.dumps({"refresh_token": token}), + content_type="application/json", + ) + + # Assert + self.assertEqual(response.status_code, 401) + + # Email verification --------------------------------------------------- + + def test_verify_email_marks_user_verified(self): + # Arrange + user = self._create_user() + token = str(user.email_verification_token) + + # Act + response = self.client.get(f"/api/auth/verify-email/{token}") + + # Assert + self.assertEqual(response.status_code, 200) + user.refresh_from_db() + self.assertTrue(user.is_email_verified) + + def test_verify_email_rejects_unknown_token(self): + # Arrange + token = uuid.uuid4() + + # Act + response = self.client.get(f"/api/auth/verify-email/{token}") + + # Assert + self.assertEqual(response.status_code, 404) + + def test_resend_verification_rejects_unknown_email(self): + # Arrange + payload = {"email": "missing@example.com"} + + # Act + response = self.client.post(f"/api/auth/resend-verification?email={payload['email']}") + + # Assert + self.assertEqual(response.status_code, 404) + + # Profiles ------------------------------------------------------------- + + def test_get_profile_returns_schema_fields(self): + # Arrange + user = self._create_user(major=self.major_cs, university=self.university_gilan) + user.is_email_verified = True + user.save(update_fields=["is_email_verified"]) + tokens = self._login_and_get_tokens(user) + + # Act + response = self.client.get("/api/auth/profile", **self._auth_headers(tokens["access_token"])) + + # Assert + self.assertEqual(response.status_code, 200) + profile = response.json() + self.assertEqual(profile["major"], user.get_major_display()) + self.assertEqual(profile["university"], user.get_university_display()) + + def test_get_profile_requires_authentication(self): + # Arrange + # No token supplied. + + # Act + response = self.client.get("/api/auth/profile") + + # Assert + self.assertEqual(response.status_code, 401) + + def test_update_profile_persists_changes(self): + # Arrange + user = self._create_user(is_email_verified=True) + tokens = self._login_and_get_tokens(user) + payload = {"bio": "Updated bio", "year_of_study": 4} + + # Act + response = self.client.put( + "/api/auth/profile", + data=json.dumps(payload), + content_type="application/json", + **self._auth_headers(tokens["access_token"]), + ) + + # Assert + self.assertEqual(response.status_code, 200) + user.refresh_from_db() + self.assertEqual(user.bio, payload["bio"]) + self.assertEqual(user.year_of_study, payload["year_of_study"]) + + @override_settings(MEDIA_URL="/media/", MEDIA_ROOT=tempfile.gettempdir()) + def test_upload_profile_picture_succeeds(self): + # Arrange + user = self._create_user(is_email_verified=True) + tokens = self._login_and_get_tokens(user) + image = SimpleUploadedFile( + "avatar.png", b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR", content_type="image/png" + ) + + # Act + response = self.client.post( + "/api/auth/profile/picture", {"file": image}, **self._auth_headers(tokens["access_token"]) + ) + + # Assert + self.assertEqual(response.status_code, 200) + profile = self.client.get( + "/api/auth/profile", **self._auth_headers(tokens["access_token"]) + ).json() + self.assertIn("profile_pictures", profile["profile_picture"]) + + def test_upload_profile_picture_requires_file(self): + # Arrange + user = self._create_user(is_email_verified=True) + tokens = self._login_and_get_tokens(user) + + # Act + response = self.client.post( + "/api/auth/profile/picture", **self._auth_headers(tokens["access_token"]) + ) + + # Assert + self.assertEqual(response.status_code, 400) + + def test_upload_profile_picture_rejects_invalid_type(self): + # Arrange + user = self._create_user(is_email_verified=True) + tokens = self._login_and_get_tokens(user) + text_file = SimpleUploadedFile("doc.txt", b"text", content_type="text/plain") + + # Act + response = self.client.post( + "/api/auth/profile/picture", + {"file": text_file}, + **self._auth_headers(tokens["access_token"]), + ) + + # Assert + self.assertEqual(response.status_code, 400) + + def test_upload_profile_picture_rejects_large_files(self): + # Arrange + user = self._create_user(is_email_verified=True) + tokens = self._login_and_get_tokens(user) + large_content = b"x" * (5 * 1024 * 1024 + 1) + large_file = SimpleUploadedFile("large.png", large_content, content_type="image/png") + + # Act + response = self.client.post( + "/api/auth/profile/picture", + {"file": large_file}, + **self._auth_headers(tokens["access_token"]), + ) + + # Assert + self.assertEqual(response.status_code, 400) + + def test_delete_profile_picture_removes_file(self): + # Arrange + temp_media = tempfile.mkdtemp() + self.addCleanup(lambda: shutil.rmtree(temp_media, ignore_errors=True)) + user = self._create_user(is_email_verified=True) + tokens = self._login_and_get_tokens(user) + with override_settings(MEDIA_ROOT=temp_media, MEDIA_URL="/media/"): + image = SimpleUploadedFile("avatar.png", b"data", content_type="image/png") + self.client.post( + "/api/auth/profile/picture", + {"file": image}, + **self._auth_headers(tokens["access_token"]), + ) + + # Act + response = self.client.delete( + "/api/auth/profile/picture", **self._auth_headers(tokens["access_token"]) + ) + + # Assert + self.assertEqual(response.status_code, 200) + user.refresh_from_db() + self.assertFalse(bool(user.profile_picture)) + + # Password reset ------------------------------------------------------ + + def test_request_password_reset_enqueues_email(self): + # Arrange + user = self._create_user() + + # Act + response = self.client.post( + "/api/auth/request-password-reset", + data=json.dumps({"email": user.email}), + content_type="application/json", + ) + + # Assert + self.assertEqual(response.status_code, 200) + user.refresh_from_db() + self.assertIsNotNone(user.password_reset_token) + self.mock_password_reset_task.assert_called_once() + + def test_request_password_reset_unknown_email_returns_error(self): + # Arrange + payload = {"email": "missing@example.com"} + + # Act + response = self.client.post( + "/api/auth/request-password-reset", + data=json.dumps(payload), + content_type="application/json", + ) + + # Assert + self.assertEqual(response.status_code, 400) + + def test_reset_password_confirm_updates_credentials(self): + # Arrange + user = self._create_user() + user.set_password_reset_token() + payload = {"token": str(user.password_reset_token), "new_password": "BrandNewPass!9"} + + # Act + response = self.client.post( + "/api/auth/reset-password-confirm", + data=json.dumps(payload), + content_type="application/json", + ) + + # Assert + self.assertEqual(response.status_code, 200) + user.refresh_from_db() + self.assertIsNone(user.password_reset_token) + self.assertTrue(user.check_password(payload["new_password"])) + + def test_reset_password_confirm_rejects_expired_token(self): + # Arrange + user = self._create_user() + user.set_password_reset_token() + user.password_reset_token_expires_at = timezone.now() - timedelta(minutes=1) + user.save(update_fields=["password_reset_token_expires_at"]) + payload = {"token": str(user.password_reset_token), "new_password": "New!!!Pass"} + + # Act + response = self.client.post( + "/api/auth/reset-password-confirm", + data=json.dumps(payload), + content_type="application/json", + ) + + # Assert + self.assertEqual(response.status_code, 400) + + def test_reset_password_confirm_rejects_unknown_token(self): + # Arrange + payload = {"token": str(uuid.uuid4()), "new_password": "AnotherPass!9"} + + # Act + response = self.client.post( + "/api/auth/reset-password-confirm", + data=json.dumps(payload), + content_type="application/json", + ) + + # Assert + self.assertEqual(response.status_code, 400) + + # Admin utilities ----------------------------------------------------- + + def test_list_deleted_users_requires_privileged_user(self): + # Arrange + user = self._create_user(is_email_verified=True) + tokens = self._login_and_get_tokens(user) + + # Act + response = self.client.get( + "/api/auth/users/deleted", **self._auth_headers(tokens["access_token"]) + ) + + # Assert + self.assertEqual(response.status_code, 403) + + def test_list_deleted_users_returns_payload_for_staff(self): + # Arrange + deleted = self._create_user(is_deleted=True, deleted_at=timezone.now()) + staff = self._create_user(is_email_verified=True, is_staff=True) + tokens = self._login_and_get_tokens(staff) + + # Act + response = self.client.get( + "/api/auth/users/deleted", **self._auth_headers(tokens["access_token"]) + ) + + # Assert + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertTrue(any(item["id"] == deleted.id for item in payload)) + + def test_restore_user_requires_privileged_user(self): + # Arrange + target = self._create_user(is_deleted=True, deleted_at=timezone.now()) + user = self._create_user(is_email_verified=True) + tokens = self._login_and_get_tokens(user) + + # Act + response = self.client.post( + f"/api/auth/users/{target.id}/restore", **self._auth_headers(tokens["access_token"]) + ) + + # Assert + self.assertEqual(response.status_code, 403) + + def test_restore_user_restores_record_for_staff(self): + # Arrange + target = self._create_user(is_deleted=True, deleted_at=timezone.now()) + staff = self._create_user(is_email_verified=True, is_staff=True) + tokens = self._login_and_get_tokens(staff) + + # Act + response = self.client.post( + f"/api/auth/users/{target.id}/restore", **self._auth_headers(tokens["access_token"]) + ) + + # Assert + self.assertEqual(response.status_code, 200) + target.refresh_from_db() + self.assertFalse(target.is_deleted) + + def test_restore_user_missing_returns_error(self): + # Arrange + staff = self._create_user(is_email_verified=True, is_staff=True) + tokens = self._login_and_get_tokens(staff) + + # Act + response = self.client.post( + "/api/auth/users/999/restore", **self._auth_headers(tokens["access_token"]) + ) + + # Assert + self.assertEqual(response.status_code, 400) + + # Username checks ------------------------------------------------------ + + def test_check_username_reports_existing(self): + # Arrange + user = self._create_user() + + # Act + response = self.client.get("/api/auth/check-username", {"username": user.username}) + + # Assert + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["exists"]) + + def test_check_username_reports_availability(self): + # Arrange + username = "available_user" + + # Act + response = self.client.get("/api/auth/check-username", {"username": username}) + + # Assert + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["exists"]) diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/test_events.py b/backend/tests/unit/test_events.py new file mode 100644 index 0000000..0edc80d --- /dev/null +++ b/backend/tests/unit/test_events.py @@ -0,0 +1,1197 @@ +import hashlib +import uuid +from datetime import timedelta +from types import SimpleNamespace +from unittest import mock + +from celery.exceptions import SoftTimeLimitExceeded +from django.http import QueryDict +from django.test import SimpleTestCase, TestCase, override_settings +from django.utils import timezone + +from django.contrib.admin import AdminSite + +from events.admin import EventAdmin, EventEmailLogAdmin, RegistrationAdmin +from events.admin_forms import AnnouncementForm +from events.models import Event, EventEmailLog, Registration +from events.resources import RegistrationResource +from events.tasks import ( + _build_email_context, + _event_recipients, + _event_url, + _send_html_email, + queue_event_announcement, + queue_invites_to_non_registered_users, + queue_skyroom_credentials, + send_event_announcement_to_user, + send_event_reminder_task, + send_event_reminder_to_user, + send_invite_to_user, + send_registration_cancellation_email, + send_registration_confirmation_email, + send_skyroom_credentials_individual_task, + send_skyroom_credentials_to_user, +) +from users.models import User + + +class EventEmailLogUtilsTests(SimpleTestCase): + def test_hash_context_returns_none_for_missing_context(self): + # Arrange / Act + result = EventEmailLog._hash_context(None) + + # Assert + self.assertIsNone(result) + + def test_hash_context_normalizes_non_string_inputs(self): + # Arrange + value = 1234 + expected = hashlib.sha256(str(value).encode("utf-8")).hexdigest() + + # Act + result = EventEmailLog._hash_context(value) + + # Assert + self.assertEqual(result, expected) + + +class EventTasksUtilityTests(SimpleTestCase): + def test_build_email_context_joined_values(self): + # Arrange + parts = ("announce", "", None, "body", "more") + + # Act + result = _build_email_context(*parts) + + # Assert + self.assertEqual(result, "announce|body|more") + + def test_build_email_context_returns_none_for_only_empty_parts(self): + # Arrange + parts = ("", None, "") + + # Act + result = _build_email_context(*parts) + + # Assert + self.assertIsNone(result) + + @override_settings(FRONTEND_ROOT="https://app.local/") + def test_event_url_prefers_slug(self): + # Arrange + event = SimpleNamespace(slug="my-event", id=1) + + # Act + result = _event_url(event) + + # Assert + self.assertEqual(result, "https://app.local/events/my-event") + + @override_settings(FRONTEND_ROOT="https://app.local/") + def test_event_url_falls_back_to_id_when_slug_missing(self): + # Arrange + event = SimpleNamespace(slug=None, id=42) + + # Act + result = _event_url(event) + + # Assert + self.assertEqual(result, "https://app.local/events/42") + + @override_settings(DEFAULT_FROM_EMAIL="noreply@example.com") + @mock.patch("events.tasks.EmailMultiAlternatives") + def test_send_html_email_attaches_html_body(self, mock_email_class): + # Arrange + html_body = "

Hello World

" + expected_text = "Hello World" + email_instance = mock_email_class.return_value + + # Act + _send_html_email("Subject", html_body, "target@example.com") + + # Assert + mock_email_class.assert_called_once_with( + subject="Subject", + body=expected_text, + from_email="noreply@example.com", + to=["target@example.com"], + ) + email_instance.attach_alternative.assert_called_once_with(html_body, "text/html") + email_instance.send.assert_called_once() + + +class RegistrationResourceTests(SimpleTestCase): + def setUp(self): + self.resource = RegistrationResource() + + def test_dehydrate_ticket_id_truncates_to_eight_characters(self): + # Arrange + ticket_id = uuid.uuid4() + record = SimpleNamespace(ticket_id=ticket_id) + expected = str(ticket_id)[:8] + + # Act + result = self.resource.dehydrate_ticket_id(record) + + # Assert + self.assertEqual(result, expected) + + def test_dehydrate_ticket_id_handles_missing_values(self): + # Arrange + record = SimpleNamespace(ticket_id=None) + + # Act + result = self.resource.dehydrate_ticket_id(record) + + # Assert + self.assertEqual(result, "") + + +class AnnouncementFormTests(SimpleTestCase): + def test_statuses_field_initializes_with_confirmed_and_attended(self): + # Arrange + form = AnnouncementForm() + + # Act + initial = form.fields["statuses"].initial + + # Assert + expected = [ + Registration.StatusChoices.CONFIRMED, + Registration.StatusChoices.ATTENDED, + ] + self.assertEqual(initial, expected) + + +class EventEmailLogFactoryMixin: + def create_user(self): + unique = uuid.uuid4().hex + return User.objects.create_user( + email=f"user_{unique}@example.com", + username=f"user_{unique[:10]}", + password="pass1234", + ) + + def create_event(self, **kwargs): + now = timezone.now() + defaults = { + "title": f"Event {uuid.uuid4().hex[:6]}", + "description": "Fixture event", + "start_time": now, + "end_time": now + timedelta(hours=1), + "slug": f"event-{uuid.uuid4().hex[:6]}", + "price": 0, + } + defaults.update(kwargs) + return Event.objects.create(**defaults) + + +class EventEmailLogModelTests(EventEmailLogFactoryMixin, TestCase): + def test_claim_creates_pending_log(self): + # Arrange + event = self.create_event() + user = self.create_user() + context = "send-invite" + + # Act + log, skipped = EventEmailLog.claim( + event_id=event.id, + user_id=user.id, + kind=EventEmailLog.KIND_INVITE_NON_REGISTERED, + context=context, + ) + + # Assert + self.assertFalse(skipped) + self.assertEqual(log.status, EventEmailLog.STATUS_PENDING) + self.assertEqual(log.context_hash, EventEmailLog._hash_context(context)) + + def test_claim_returns_existing_pending_log(self): + # Arrange + event = self.create_event() + user = self.create_user() + context = "announcement" + context_hash = EventEmailLog._hash_context(context) + existing = EventEmailLog.objects.create( + event=event, + user=user, + kind=EventEmailLog.KIND_EVENT_ANNOUNCEMENT, + context_hash=context_hash, + status=EventEmailLog.STATUS_PENDING, + ) + + # Act + log, skipped = EventEmailLog.claim( + event_id=event.id, + user_id=user.id, + kind=EventEmailLog.KIND_EVENT_ANNOUNCEMENT, + context=context, + ) + + # Assert + self.assertTrue(skipped) + self.assertEqual(log.pk, existing.pk) + self.assertEqual(log.status, EventEmailLog.STATUS_PENDING) + + def test_claim_resets_failed_record(self): + # Arrange + event = self.create_event() + user = self.create_user() + context = "retry" + context_hash = EventEmailLog._hash_context(context) + log = EventEmailLog.objects.create( + event=event, + user=user, + kind=EventEmailLog.KIND_EVENT_ANNOUNCEMENT, + context_hash=context_hash, + status=EventEmailLog.STATUS_FAILED, + error="boom", + sent_at=timezone.now(), + ) + + # Act + claimed, skipped = EventEmailLog.claim( + event_id=event.id, + user_id=user.id, + kind=EventEmailLog.KIND_EVENT_ANNOUNCEMENT, + context=context, + ) + + # Assert + self.assertFalse(skipped) + self.assertEqual(claimed.pk, log.pk) + self.assertEqual(claimed.status, EventEmailLog.STATUS_PENDING) + self.assertEqual(claimed.error, "") + self.assertIsNone(claimed.sent_at) + + def test_mark_sent_sets_sent_timestamp_and_status(self): + # Arrange + event = self.create_event() + user = self.create_user() + log = EventEmailLog.objects.create( + event=event, + user=user, + kind=EventEmailLog.KIND_EVENT_ANNOUNCEMENT, + ) + + # Act + log.mark_sent() + + # Assert + self.assertEqual(log.status, EventEmailLog.STATUS_SENT) + self.assertIsNotNone(log.sent_at) + + def test_mark_failed_clears_sent_at_and_records_error(self): + # Arrange + event = self.create_event() + user = self.create_user() + log = EventEmailLog.objects.create( + event=event, + user=user, + kind=EventEmailLog.KIND_EVENT_ANNOUNCEMENT, + sent_at=timezone.now(), + ) + + # Act + log.mark_failed("timeout") + + # Assert + self.assertEqual(log.status, EventEmailLog.STATUS_FAILED) + self.assertEqual(log.error, "timeout") + self.assertIsNone(log.sent_at) + + +class EventModelTests(EventEmailLogFactoryMixin, TestCase): + def test_description_html_renders_markdown(self): + # Arrange + event = self.create_event(description="**bold** content") + + # Act + rendered = event.description_html + + # Assert + self.assertIn("bold", rendered) + + def test_is_registration_open_follows_window(self): + # Arrange + now = timezone.now() + event = self.create_event( + registration_start_date=now - timedelta(hours=2), + registration_end_date=now + timedelta(hours=2), + ) + + # Act / Assert + with mock.patch("events.models.timezone.now", return_value=now): + self.assertTrue(event.is_registration_open) + + def test_is_registration_open_closed_outside_window(self): + # Arrange + now = timezone.now() + event = self.create_event( + registration_start_date=now + timedelta(hours=1), + registration_end_date=now + timedelta(hours=2), + ) + + # Act / Assert + with mock.patch("events.models.timezone.now", return_value=now): + self.assertFalse(event.is_registration_open) + + def test_current_attendees_count_filters_statuses(self): + # Arrange + event = self.create_event() + user_one = self.create_user() + user_two = self.create_user() + Registration.objects.create( + event=event, + user=user_one, + status=Registration.StatusChoices.CONFIRMED, + ) + Registration.objects.create( + event=event, + user=user_two, + status=Registration.StatusChoices.CANCELLED, + ) + Registration.objects.create( + event=event, + user=self.create_user(), + status=Registration.StatusChoices.ATTENDED, + ) + + # Act + count = event.current_attendees_count + + # Assert + self.assertEqual(count, 2) + + def test_has_available_slots_respects_capacity(self): + # Arrange + event = self.create_event(capacity=2) + for _ in range(2): + Registration.objects.create( + event=event, + user=self.create_user(), + status=Registration.StatusChoices.CONFIRMED, + ) + + # Act + available_after_full = event.has_available_slots + + # Assert + self.assertFalse(available_after_full) + + def test_has_available_slots_allows_unlimited_capacity(self): + # Arrange + event = self.create_event(capacity=None) + + # Act + available = event.has_available_slots + + # Assert + self.assertTrue(available) + + +class EventTaskBehaviorTests(EventEmailLogFactoryMixin, TestCase): + def test_event_recipients_filters_by_status_and_email(self): + # Arrange + event = self.create_event() + verified = self.create_user() + verified.is_email_verified = True + verified.save(update_fields=["is_email_verified"]) + Registration.objects.create( + event=event, + user=verified, + status=Registration.StatusChoices.CONFIRMED, + ) + unverified = self.create_user() + Registration.objects.create( + event=event, + user=unverified, + status=Registration.StatusChoices.CONFIRMED, + ) + + # Act + recipients = list(_event_recipients(event, statuses=[Registration.StatusChoices.CONFIRMED])) + + # Assert + self.assertEqual(len(recipients), 1) + self.assertEqual(recipients[0].user_id, verified.id) + + @override_settings(DEFAULT_FROM_EMAIL="noreply@example.com") + @mock.patch("events.tasks.EmailMultiAlternatives") + @mock.patch("events.tasks.render_to_string", return_value="

ok

") + @mock.patch("events.tasks.strip_tags", side_effect=lambda html: "ok") + @mock.patch("events.tasks.markdown.markdown", return_value="converted") + def test_send_registration_confirmation_email_sends_message( + self, + mock_markdown, + mock_strip, + mock_render, + mock_email_class, + ): + # Arrange + registration = SimpleNamespace( + pk=1, + user=SimpleNamespace(email="user@example.com", username="user-one"), + event=SimpleNamespace( + title="Title", + registration_success_markdown="**done**", + ), + ) + manager = mock.MagicMock() + manager.select_related.return_value.get.return_value = registration + + mock_email_instance = mock_email_class.return_value + + # Act + with mock.patch("events.tasks.Registration.objects", manager): + send_registration_confirmation_email.run("1") + + # Assert helpers + mock_markdown.assert_called_once_with( + "**done**", + extensions=["extra", "sane_lists", "toc"], + ) + mock_render.assert_called_once_with( + "emails/event_registration_confirmation.html", + { + "user": registration.user, + "event": registration.event, + "registration": registration, + "success_html": "converted", + }, + ) + mock_strip.assert_called_once_with("

ok

") + + # Assert + mock_email_class.assert_called_once() + mock_email_instance.attach_alternative.assert_called_once() + mock_email_instance.send.assert_called_once() + + +class EventAdminTests(EventEmailLogFactoryMixin, TestCase): + def setUp(self): + self.site = AdminSite() + self.event_admin = EventAdmin(Event, self.site) + self.registration_admin = RegistrationAdmin(Registration, self.site) + self.event_admin.message_user = mock.Mock() + self.registration_admin.message_user = mock.Mock() + + def test_price_display_returns_label_for_free(self): + # Arrange + now = timezone.now() + event = Event( + title="Free Event", + description="desc", + start_time=now, + end_time=now + timedelta(hours=1), + price=None, + ) + + # Act + result = self.event_admin.price_display(event) + + # Assert + self.assertEqual(result, "رایگان") + + @mock.patch("events.admin.jdate", return_value="JDATE") + def test_start_time_display_calls_jdate(self, mock_jdate): + event = self.create_event() + + result = self.event_admin.start_time_display(event) + + mock_jdate.assert_called_once_with(event.start_time) + self.assertEqual(result, "JDATE") + + @mock.patch("events.admin.jdate", return_value="JDATE") + def test_end_time_display_calls_jdate(self, mock_jdate): + event = self.create_event() + + result = self.event_admin.end_time_display(event) + + mock_jdate.assert_called_once_with(event.end_time) + self.assertEqual(result, "JDATE") + + def test_capacity_display_handles_unlimited(self): + event = self.create_event(capacity=None) + + result = self.event_admin.capacity_display(event) + + self.assertEqual(result, "نامحدود") + + @mock.patch("events.admin.Event.current_attendees_count", new_callable=mock.PropertyMock, return_value=7) + def test_attendees_display_returns_current_attendees(self, _mock_count): + event = self.create_event() + + result = self.event_admin.attendees_display(event) + + self.assertEqual(result, 7) + + @mock.patch("events.admin.Event.is_registration_open", new_callable=mock.PropertyMock, return_value=True) + def test_is_registration_open_display_returns_bool(self, _mock_open): + event = self.create_event() + + self.assertTrue(self.event_admin.is_registration_open_display(event)) + + def test_make_draft_updates_status(self): + event = self.create_event(status=Event.StatusChoices.PUBLISHED) + queryset = Event.all_objects.filter(pk=event.pk) + + self.event_admin.make_draft(None, queryset) + + self.assertEqual(Event.objects.get(pk=event.pk).status, Event.StatusChoices.DRAFT) + + def test_make_cancelled_updates_status(self): + event = self.create_event(status=Event.StatusChoices.DRAFT) + queryset = Event.all_objects.filter(pk=event.pk) + + self.event_admin.make_cancelled(None, queryset) + + self.assertEqual(Event.objects.get(pk=event.pk).status, Event.StatusChoices.CANCELLED) + + def test_make_completed_updates_status(self): + event = self.create_event(status=Event.StatusChoices.PUBLISHED) + queryset = Event.objects.filter(pk=event.pk) + + self.event_admin.make_completed(None, queryset) + + self.assertEqual(Event.objects.get(pk=event.pk).status, Event.StatusChoices.COMPLETED) + + def test_restore_events_marks_is_deleted_false(self): + event = self.create_event() + event.delete() + queryset = Event.all_objects.filter(pk=event.pk) + + self.event_admin.restore_events(None, queryset) + + self.assertFalse(Event.all_objects.get(pk=event.pk).is_deleted) + + def test_action_send_skyroom_credentials_queues_task(self): + event = self.create_event() + + with mock.patch("events.admin.queue_skyroom_credentials.delay") as mock_delay: + result = self.event_admin.action_send_skyroom_credentials(mock.Mock(), event.pk) + + mock_delay.assert_called_once_with(event.pk) + self.assertEqual(result, mock.ANY) + + def test_action_send_reminder_now_queues_task(self): + event = self.create_event() + + with mock.patch("events.admin.send_event_reminder_task.delay") as mock_delay: + result = self.event_admin.action_send_reminder_now(mock.Mock(), event.pk) + + mock_delay.assert_called_once_with(event.pk) + self.assertEqual(result, mock.ANY) + + def test_action_send_announcement_dispatches_queue(self): + event = self.create_event() + data = QueryDict(mutable=True) + data.update({"subject": "Hello", "body_html": "

hi

"}) + data.setlist("statuses", [Registration.StatusChoices.CONFIRMED]) + request = SimpleNamespace(method="POST", POST=data, user=self.create_user()) + + with mock.patch("events.admin.queue_event_announcement") as mock_queue, \ + mock.patch("events.admin.redirect", return_value="redirected") as mock_redirect: + result = self.event_admin.action_send_announcement(request, event.pk) + + mock_queue.delay.assert_called_once() + mock_redirect.assert_called_once() + self.assertEqual(result, "redirected") + + def test_action_invite_other_users_queues_task(self): + event = self.create_event() + + with mock.patch("events.admin.queue_invites_to_non_registered_users.delay") as mock_delay: + result = self.event_admin.action_invite_other_users(mock.Mock(), event.pk) + + mock_delay.assert_called_once_with(event.pk) + self.assertEqual(result, mock.ANY) + + def test_make_published_updates_status(self): + event = self.create_event(status=Event.StatusChoices.DRAFT) + queryset = Event.objects.filter(pk=event.pk) + + self.event_admin.make_published(None, queryset) + + self.assertEqual(Event.objects.get(pk=event.pk).status, Event.StatusChoices.PUBLISHED) + + def test_confirm_registrations_sets_status(self): + # Arrange + event = self.create_event() + user = self.create_user() + user.is_email_verified = False + user.save(update_fields=["is_email_verified"]) + registration = Registration.objects.create( + event=event, + user=user, + status=Registration.StatusChoices.PENDING, + ) + + # Act + self.registration_admin.confirm_registrations(None, Registration.objects.filter(pk=registration.pk)) + + # Assert + self.assertEqual( + Registration.objects.get(pk=registration.pk).status, + Registration.StatusChoices.CONFIRMED, + ) + + +class RegistrationAdminTests(EventEmailLogFactoryMixin, TestCase): + def setUp(self): + self.site = AdminSite() + self.admin = RegistrationAdmin(Registration, self.site) + self.admin.message_user = mock.Mock() + + def test_cancel_registrations_sets_status(self): + registration = Registration.objects.create( + event=self.create_event(), + user=self.create_user(), + status=Registration.StatusChoices.PENDING, + ) + + self.admin.cancel_registrations(None, Registration.objects.filter(pk=registration.pk)) + + self.assertEqual( + Registration.objects.get(pk=registration.pk).status, + Registration.StatusChoices.CANCELLED, + ) + + def test_mark_attended_updates_status(self): + registration = Registration.objects.create( + event=self.create_event(), + user=self.create_user(), + status=Registration.StatusChoices.CONFIRMED, + ) + + self.admin.mark_attended(None, Registration.objects.filter(pk=registration.pk)) + + self.assertEqual( + Registration.objects.get(pk=registration.pk).status, + Registration.StatusChoices.ATTENDED, + ) + + def test_restore_registrations_calls_restore(self): + registration = Registration.objects.create( + event=self.create_event(), + user=self.create_user(), + status=Registration.StatusChoices.PENDING, + ) + registration.delete() + + with mock.patch.object(Registration, "objects", Registration.all_objects): + self.admin.restore_registrations(None, Registration.all_objects.filter(pk=registration.pk)) + + self.assertFalse(Registration.all_objects.get(pk=registration.pk).is_deleted) + + def test_action_email_selected_sends_and_redirects(self): + event = self.create_event() + registration = Registration.objects.create( + event=event, + user=self.create_user(), + status=Registration.StatusChoices.PENDING, + ) + data = QueryDict(mutable=True) + data.update({"subject": "Title", "body_html": "

body

"}) + request = SimpleNamespace(method="POST", POST=data, user=self.create_user()) + + with mock.patch("events.admin.render_to_string", return_value="

ok

"), \ + mock.patch("events.admin._send_html_email") as mock_send, \ + mock.patch("events.admin.redirect", return_value="redirected") as mock_redirect: + self.admin.action_email_selected(request, registration.pk) + + mock_send.assert_called_once() + mock_redirect.assert_called_once() + + def test_action_send_skyroom_credentials_queues_task(self): + registration = Registration.objects.create( + event=self.create_event(), + user=self.create_user(), + status=Registration.StatusChoices.CONFIRMED, + ) + + with mock.patch("events.admin.send_skyroom_credentials_individual_task.delay") as mock_delay: + result = self.admin.action_send_skyroom_credentials(mock.Mock(), registration.pk) + + mock_delay.assert_called_once_with(registration.pk) + self.assertEqual(result, mock.ANY) + + +class EventEmailLogAdminTests(EventEmailLogFactoryMixin, TestCase): + def setUp(self): + self.site = AdminSite() + self.admin = EventEmailLogAdmin(EventEmailLog, self.site) + self.admin.message_user = mock.Mock() + + def test_user_email_returns_dash_when_missing(self): + event = self.create_event() + user = self.create_user() + user.email = "" + log = EventEmailLog.objects.create( + event=event, + user=user, + kind=EventEmailLog.KIND_EVENT_ANNOUNCEMENT, + ) + + self.assertEqual(self.admin.user_email(log), "—") + + def test_resend_selected_emails_requeues_and_clears_error(self): + event = self.create_event() + user = self.create_user() + log = EventEmailLog.objects.create( + event=event, + user=user, + kind=EventEmailLog.KIND_INVITE_NON_REGISTERED, + status=EventEmailLog.STATUS_FAILED, + error="boom", + ) + + with mock.patch("events.admin.send_invite_to_user.delay") as mock_delay: + self.admin.resend_selected_emails(mock.Mock(), EventEmailLog.objects.filter(pk=log.pk)) + + log.refresh_from_db() + self.assertEqual(log.status, EventEmailLog.STATUS_PENDING) + self.assertEqual(log.error, "") + mock_delay.assert_called_once_with(log.event_id, log.user_id) + + +class EventTasksCoverageTests(EventEmailLogFactoryMixin, TestCase): + def _dummy_registration(self): + user = self.create_user() + user.is_email_verified = True + user.save(update_fields=["is_email_verified"]) + event = self.create_event() + registration = Registration.objects.create( + event=event, + user=user, + status=Registration.StatusChoices.CONFIRMED, + ) + return registration + + def test_send_registration_cancellation_email_returns_when_email_missing(self): + registration = SimpleNamespace( + user=SimpleNamespace(email=None), + event=SimpleNamespace(title="Title"), + ) + manager = mock.MagicMock() + manager.select_related.return_value.get.return_value = registration + + with mock.patch("events.tasks.Registration.objects", manager), \ + mock.patch("events.tasks.EmailMultiAlternatives") as mock_email: + send_registration_cancellation_email.run("1") + + mock_email.assert_not_called() + + def test_send_registration_cancellation_email_retries_on_failure(self): + registration = SimpleNamespace( + user=SimpleNamespace(email="user@example.com"), + event=SimpleNamespace(title="Title"), + ) + manager = mock.MagicMock() + manager.select_related.return_value.get.return_value = registration + email_instance = mock.MagicMock() + email_instance.send.side_effect = RuntimeError("boom") + mock_email_class = mock.MagicMock(return_value=email_instance) + + with mock.patch("events.tasks.Registration.objects", manager), \ + mock.patch("events.tasks.EmailMultiAlternatives", mock_email_class), \ + mock.patch("events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("events.tasks.strip_tags", return_value="ok"), \ + mock.patch.object(send_registration_cancellation_email, "retry", side_effect=RuntimeError("retry")) as mock_retry: + with self.assertRaises(RuntimeError): + send_registration_cancellation_email.run("1") + + mock_retry.assert_called_once() + + def test_send_skyroom_credentials_individual_task_sends_email(self): + user = SimpleNamespace(email="user@example.com") + event = SimpleNamespace( + title="E", + slug="slug", + online_link="https://example.com", + ) + registration = SimpleNamespace( + event=event, + user=user, + ticket_id="abcdefghijk", + ) + manager = mock.MagicMock() + manager.get.return_value = registration + email_instance = mock.MagicMock() + + with mock.patch("events.tasks.Registration.objects", manager), \ + mock.patch("events.tasks.EmailMultiAlternatives", mock.MagicMock(return_value=email_instance)), \ + mock.patch("events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("events.tasks.strip_tags", return_value="ok"): + send_skyroom_credentials_individual_task.run(1) + + email_instance.send.assert_called_once() + + def test_send_event_reminder_task_sends_messages(self): + event = SimpleNamespace(title="Ev", slug="slug") + + class DummyRegs: + def __init__(self, ids): + self.ids = ids + def select_related(self, *args, **kwargs): + return self + def distinct(self): + return self + def values_list(self, *args, **kwargs): + return self.ids + + regs = DummyRegs([1]) + with mock.patch("events.tasks.Event.objects.get", return_value=event), \ + mock.patch("events.tasks._event_recipients", return_value=regs), \ + mock.patch("events.tasks.group") as mock_group: + mock_job = mock.MagicMock() + mock_group.return_value = mock_job + mock_job.apply_async.return_value = mock.MagicMock(id="gid") + + result = send_event_reminder_task.run(1) + + mock_group.assert_called_once() + mock_job.apply_async.assert_called_once() + self.assertEqual(result["queued"], 1) + self.assertEqual(result["group_id"], "gid") + + def test_queue_event_announcement_builds_group(self): + event = self.create_event() + class DummyQS: + def __init__(self, ids): + self.ids = ids + def select_related(self, *args, **kwargs): + return self + def exclude(self, *args, **kwargs): + return self + def distinct(self): + return self + def values_list(self, *args, **kwargs): + return self.ids + with mock.patch("events.tasks.Event.objects.get", return_value=event), \ + mock.patch("events.tasks._event_recipients", return_value=DummyQS([1, 2])), \ + mock.patch("events.tasks.group") as mock_group: + mock_job = mock.MagicMock() + mock_group.return_value = mock_job + result = queue_event_announcement.run(event.id, "subject", "

body

") + + mock_group.assert_called_once() + mock_job.apply_async.assert_called_once() + self.assertEqual(result["queued"], 2) + + def test_send_event_announcement_to_user_marks_sent(self): + event = self.create_event() + user = self.create_user() + registration = SimpleNamespace( + user=user, + event=event, + id=1, + ) + log = mock.MagicMock(status=EventEmailLog.STATUS_PENDING) + with mock.patch("events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("events.tasks.EventEmailLog.claim", return_value=(log, False)), \ + mock.patch("events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("events.tasks.strip_tags", return_value="ok"), \ + mock.patch("events.tasks.EmailMultiAlternatives", return_value=mock.MagicMock()): + mock_select.return_value.get.return_value = registration + send_event_announcement_to_user._orig_run(event.id, 1, "subject", "

body

") + + log.mark_sent.assert_called_once() + + def test_send_event_announcement_to_user_returns_skip(self): + log = mock.MagicMock(status=EventEmailLog.STATUS_PENDING) + with mock.patch("events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("events.tasks.EventEmailLog.claim", return_value=(log, True)): + mock_select.return_value.get.return_value = SimpleNamespace(user=SimpleNamespace(id=1), event=SimpleNamespace(slug="slug")) + result = send_event_announcement_to_user._orig_run(1, 1, "subject", "

body

") + + self.assertEqual(result, {"skipped": True, "status": log.status}) + + def test_queue_invites_to_non_registered_users_uses_group(self): + event = self.create_event() + class DummyUserQS: + def __init__(self, ids): + self.ids = ids + def filter(self, *args, **kwargs): + return self + def exclude(self, *args, **kwargs): + return self + def distinct(self): + return self + def values_list(self, *args, **kwargs): + return self.ids + with mock.patch("events.tasks.Event.objects.get", return_value=event), \ + mock.patch("events.tasks.User.objects.all", return_value=DummyUserQS([1])), \ + mock.patch("events.tasks.group") as mock_group: + mock_job = mock.MagicMock() + mock_group.return_value = mock_job + result = queue_invites_to_non_registered_users.run(event.id) + + mock_job.apply_async.assert_called_once() + self.assertEqual(result["queued"], 1) + + def test_send_invite_to_user_skips_when_claimed(self): + log = mock.MagicMock(status=EventEmailLog.STATUS_PENDING) + with mock.patch("events.tasks.Event.objects.get", return_value=self.create_event()), \ + mock.patch("events.tasks.User.objects.get", return_value=self.create_user()), \ + mock.patch("events.tasks.EventEmailLog.claim", return_value=(log, True)): + result = send_invite_to_user._orig_run(1, 1) + + self.assertEqual(result, {"skipped": True, "status": log.status}) + + def test_send_invite_to_user_sends_email(self): + msg_instance = mock.MagicMock() + target_user = self.create_user() + with mock.patch("events.tasks.Event.objects.get", return_value=self.create_event()), \ + mock.patch("events.tasks.User.objects.get", return_value=target_user), \ + mock.patch("events.tasks.EventEmailLog.claim", return_value=(mock.MagicMock(), False)), \ + mock.patch("events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("events.tasks._build_email_context", return_value="ctx"), \ + mock.patch("events.tasks.EmailMultiAlternatives", return_value=msg_instance): + result = send_invite_to_user._orig_run(1, 1) + + msg_instance.send.assert_called_once() + self.assertEqual(result, f"Email sent to {target_user.email}") + + def test_queue_skyroom_credentials_builds_group(self): + event = self.create_event() + class DummyRegQS: + def __init__(self, ids): + self.ids = ids + def select_related(self, *args, **kwargs): + return self + def exclude(self, *args, **kwargs): + return self + def distinct(self): + return self + def values_list(self, *args, **kwargs): + return self.ids + with mock.patch("events.tasks.Event.objects.get", return_value=event), \ + mock.patch("events.tasks._event_recipients", return_value=DummyRegQS([1])), \ + mock.patch("events.tasks.group") as mock_group: + mock_job = mock.MagicMock() + mock_group.return_value = mock_job + result = queue_skyroom_credentials.run(event.id) + + mock_job.apply_async.assert_called_once() + self.assertEqual(result["queued"], 1) + + def test_send_skyroom_credentials_to_user_skips_when_claimed(self): + log = mock.MagicMock(status=EventEmailLog.STATUS_PENDING) + with mock.patch("events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("events.tasks.EventEmailLog.claim", return_value=(log, True)): + mock_select.return_value.get.return_value = SimpleNamespace( + user=SimpleNamespace(id=1, email="user@example.com"), + event=SimpleNamespace(id=1, slug="slug", online_link="https://example.com", title="E"), + ticket_id=uuid.uuid4(), + ) + result = send_skyroom_credentials_to_user._orig_run(1, 1) + + self.assertEqual(result, {"skipped": True, "status": log.status}) + + def test_send_skyroom_credentials_to_user_sends_email(self): + msg_instance = mock.MagicMock() + with mock.patch("events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("events.tasks.EventEmailLog.claim", return_value=(mock.MagicMock(), False)), \ + mock.patch("events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("events.tasks.strip_tags", return_value="ok"), \ + mock.patch("events.tasks.EmailMultiAlternatives", return_value=msg_instance): + mock_select.return_value.get.return_value = SimpleNamespace( + user=SimpleNamespace(email="user@example.com", id=1), + event=SimpleNamespace(title="Title", slug="slug", online_link="https://example.com"), + ticket_id=uuid.uuid4(), + ) + send_skyroom_credentials_to_user._orig_run(1, 1) + + msg_instance.send.assert_called_once() + + def test_send_registration_confirmation_email_skips_without_email(self): + registration = SimpleNamespace( + user=SimpleNamespace(email=""), + event=SimpleNamespace(title="Title", registration_success_markdown=""), + ) + manager = mock.MagicMock() + manager.select_related.return_value.get.return_value = registration + with mock.patch("events.tasks.Registration.objects", manager), \ + mock.patch("events.tasks.EmailMultiAlternatives") as mock_email: + send_registration_confirmation_email.run("1") + + mock_email.assert_not_called() + + def test_send_registration_confirmation_email_retries_on_failure(self): + registration = SimpleNamespace( + user=SimpleNamespace(email="user@example.com"), + event=SimpleNamespace(title="Title", registration_success_markdown=""), + ) + manager = mock.MagicMock() + manager.select_related.return_value.get.return_value = registration + email_instance = mock.MagicMock() + email_instance.send.side_effect = RuntimeError("boom") + mock_email_class = mock.MagicMock(return_value=email_instance) + + with mock.patch("events.tasks.Registration.objects", manager), \ + mock.patch("events.tasks.EmailMultiAlternatives", mock_email_class), \ + mock.patch("events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("events.tasks.strip_tags", return_value="ok"), \ + mock.patch.object(send_registration_confirmation_email, "retry", side_effect=RuntimeError("retry")) as mock_retry: + with self.assertRaises(RuntimeError): + send_registration_confirmation_email.run("1") + + mock_retry.assert_called_once() + + def test_event_recipients_disregards_verification_flag(self): + event = self.create_event() + user = self.create_user() + user.is_email_verified = False + user.save(update_fields=["is_email_verified"]) + registration = Registration.objects.create( + event=event, + user=user, + status=Registration.StatusChoices.PENDING, + ) + + recipients = _event_recipients(event, only_verified=False) + + self.assertEqual(len(recipients), 1) + self.assertEqual(recipients[0].user_id, user.id) + + def test_send_skyroom_credentials_individual_task_retries_on_failure(self): + user = SimpleNamespace(email="user@example.com") + event = SimpleNamespace(title="Title", slug="slug", online_link="https://example.com") + registration = SimpleNamespace(user=user, event=event, ticket_id="abcdef") + manager = mock.MagicMock() + manager.get.return_value = registration + + with mock.patch("events.tasks.Registration.objects", manager), \ + mock.patch("events.tasks.EmailMultiAlternatives", mock.MagicMock(return_value=mock.MagicMock(send=mock.Mock(side_effect=RuntimeError("boom"))))), \ + mock.patch("events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("events.tasks.strip_tags", return_value="ok"), \ + mock.patch.object(send_skyroom_credentials_individual_task, "retry", side_effect=RuntimeError("retry")) as mock_retry: + with self.assertRaises(RuntimeError): + send_skyroom_credentials_individual_task.run(1) + + self.assertTrue(mock_retry.called) + + def test_send_event_reminder_task_propagates_failure(self): + event = SimpleNamespace(title="Ev", slug="slug") + + class DummyRegs: + def __init__(self, ids): + self.ids = ids + def select_related(self, *args, **kwargs): + return self + def distinct(self): + return self + def values_list(self, *args, **kwargs): + return self.ids + + regs = DummyRegs([1]) + with mock.patch("events.tasks.Event.objects.get", return_value=event), \ + mock.patch("events.tasks._event_recipients", return_value=regs), \ + mock.patch("events.tasks.group") as mock_group: + mock_job = mock.MagicMock() + mock_group.return_value = mock_job + mock_job.apply_async.side_effect = RuntimeError("boom") + + with self.assertRaises(RuntimeError): + send_event_reminder_task.run(1) + + def test_send_event_reminder_to_user_marks_sent(self): + event = self.create_event() + user = self.create_user() + registration = SimpleNamespace(user=user, event=event, id=1) + log = mock.MagicMock(status=EventEmailLog.STATUS_PENDING) + msg_instance = mock.MagicMock() + with mock.patch("events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("events.tasks.EventEmailLog.claim", return_value=(log, False)), \ + mock.patch("events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("events.tasks.strip_tags", return_value="ok"), \ + mock.patch("events.tasks.EmailMultiAlternatives", return_value=msg_instance): + mock_select.return_value.get.return_value = registration + result = send_event_reminder_to_user._orig_run(event.id, 1) + + msg_instance.send.assert_called_once() + log.mark_sent.assert_called_once() + self.assertEqual(result, f"Email sent to {user.email}") + + def test_send_event_announcement_to_user_handles_soft_time_limit(self): + event = self.create_event() + user = self.create_user() + registration = SimpleNamespace(user=user, event=event, id=1) + log = mock.MagicMock(status=EventEmailLog.STATUS_PENDING) + with mock.patch("events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("events.tasks.EventEmailLog.claim", return_value=(log, False)), \ + mock.patch("events.tasks.render_to_string", side_effect=SoftTimeLimitExceeded("timeout")), \ + mock.patch("events.tasks.strip_tags") as mock_strip: + mock_select.return_value.get.return_value = registration + with self.assertRaises(SoftTimeLimitExceeded): + send_event_announcement_to_user._orig_run(1, 1, "subject", "

body

") + + log.mark_failed.assert_called_once_with("Soft time limit exceeded") + + def test_send_event_announcement_to_user_handles_failure(self): + event = self.create_event() + user = self.create_user() + registration = SimpleNamespace(user=user, event=event, id=1) + log = mock.MagicMock(status=EventEmailLog.STATUS_PENDING) + with mock.patch("events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("events.tasks.EventEmailLog.claim", return_value=(log, False)), \ + mock.patch("events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("events.tasks.strip_tags", return_value="ok"), \ + mock.patch("events.tasks.EmailMultiAlternatives", return_value=mock.MagicMock(send=mock.Mock(side_effect=RuntimeError("boom")))): + mock_select.return_value.get.return_value = registration + with self.assertRaises(RuntimeError): + send_event_announcement_to_user._orig_run(1, 1, "subject", "

body

") + + log.mark_failed.assert_called_once() + + def test_send_invite_to_user_handles_failure(self): + event = self.create_event() + user = self.create_user() + log = mock.MagicMock(status=EventEmailLog.STATUS_PENDING) + with mock.patch("events.tasks.Event.objects.get", return_value=event), \ + mock.patch("events.tasks.User.objects.get", return_value=user), \ + mock.patch("events.tasks.EventEmailLog.claim", return_value=(log, False)), \ + mock.patch("events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("events.tasks._build_email_context", return_value="ctx"), \ + mock.patch("events.tasks.EmailMultiAlternatives", return_value=mock.MagicMock(send=mock.Mock(side_effect=RuntimeError("boom")))): + with self.assertRaises(RuntimeError): + send_invite_to_user._orig_run(1, 1) + + log.mark_failed.assert_called_once() + + def test_send_skyroom_credentials_to_user_handles_failure(self): + event = self.create_event() + user = self.create_user() + log = mock.MagicMock(status=EventEmailLog.STATUS_PENDING) + with mock.patch("events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("events.tasks.EventEmailLog.claim", return_value=(log, False)), \ + mock.patch("events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("events.tasks.strip_tags", return_value="ok"), \ + mock.patch("events.tasks.EmailMultiAlternatives", return_value=mock.MagicMock(send=mock.Mock(side_effect=RuntimeError("boom")))): + mock_select.return_value.get.return_value = SimpleNamespace( + user=user, + event=event, + ticket_id=uuid.uuid4(), + ) + with self.assertRaises(RuntimeError): + send_skyroom_credentials_to_user._orig_run(1, 1) + + log.mark_failed.assert_called_once() + + def test_queue_invites_to_non_registered_users_respects_filters(self): + event = self.create_event() + verified = self.create_user() + verified.is_email_verified = True + verified.save(update_fields=["is_email_verified"]) + inactive = self.create_user() + inactive.is_email_verified = True + inactive.is_active = False + inactive.save(update_fields=["is_email_verified", "is_active"]) + with mock.patch("events.tasks.group") as mock_group: + mock_job = mock.MagicMock() + mock_group.return_value = mock_job + result = queue_invites_to_non_registered_users.run(event.id, only_verified=True, only_active=True) + + mock_job.apply_async.assert_called_once() + self.assertEqual(result["queued"], 1) diff --git a/backend/tests/unit/test_payments.py b/backend/tests/unit/test_payments.py new file mode 100644 index 0000000..3b61a0d --- /dev/null +++ b/backend/tests/unit/test_payments.py @@ -0,0 +1,194 @@ +import uuid +from datetime import timedelta +from types import SimpleNamespace + +from django.core.exceptions import ValidationError +from django.test import TestCase +from django.utils import timezone + +from django.contrib.admin import AdminSite +from payments.admin import DiscountCodeAdmin +from payments.models import DiscountCode, Payment +from payments.resources import DiscountResource, PaymentResource +from events.models import Event +from users.models import User +from ninja.errors import HttpError + + +class PaymentTestMixin: + @staticmethod + def _create_user(**overrides): + data = { + "username": f"user_{uuid.uuid4().hex[:6]}", + "email": f"user_{uuid.uuid4().hex[:6]}@example.com", + "password": "Test!1234", + } + data.update(overrides) + return User.objects.create_user(**data) + + @staticmethod + def _create_event(**overrides): + now = timezone.now() + defaults = { + "title": "Sample", + "description": "Desc", + "start_time": now, + "end_time": now + timedelta(hours=2), + "registration_start_date": now - timedelta(days=1), + "registration_end_date": now + timedelta(days=5), + "slug": f"event-{uuid.uuid4().hex[:6]}", + "price": 100000, + "capacity": 10, + "status": Event.StatusChoices.PUBLISHED, + } + defaults.update(overrides) + return Event.objects.create(**defaults) + + @staticmethod + def _discount_code(**overrides): + defaults = { + "code": f"CODE{uuid.uuid4().hex[:4]}", + "value": 50, + "is_active": True, + "type": DiscountCode.Type.PERCENT, + } + defaults.update(overrides) + return DiscountCode.objects.create(**defaults) + + +class DiscountCodeModelTests(TestCase, PaymentTestMixin): + def setUp(self): + self.event = self._create_event() + self.user = self._create_user(is_email_verified=True) + + def test_zero_price_returns_zero_discount(self): + event = self._create_event(price=0) + code = self._discount_code() + code.applicable_events.add(event) + self.assertEqual(code.calculate_discount(event, self.user), (0, 0)) + + def test_inactive_raises_error(self): + code = self._discount_code(is_active=False) + code.applicable_events.add(self.event) + with self.assertRaises(HttpError): + code.calculate_discount(self.event, self.user) + + def test_start_date_validation(self): + code = self._discount_code(starts_at=timezone.now() + timedelta(days=1)) + code.applicable_events.add(self.event) + with self.assertRaises(HttpError): + code.calculate_discount(self.event, self.user) + + def test_end_date_validation(self): + code = self._discount_code(ends_at=timezone.now() - timedelta(days=1)) + code.applicable_events.add(self.event) + with self.assertRaises(HttpError): + code.calculate_discount(self.event, self.user) + + def test_applicable_events_enforcement(self): + code = self._discount_code() + other_event = self._create_event() + code.applicable_events.add(other_event) + with self.assertRaises(HttpError): + code.calculate_discount(self.event, self.user) + + def test_min_amount_guard(self): + code = self._discount_code(min_amount=200000) + code.applicable_events.add(self.event) + with self.assertRaises(HttpError): + code.calculate_discount(self.event, self.user) + + def test_usage_limit_total(self): + code = self._discount_code(usage_limit_total=1) + code.applicable_events.add(self.event) + Payment.objects.create( + user=self.user, + event=self.event, + base_amount=self.event.price, + amount=self.event.price, + discount_amount=0, + status=Payment.OrderStatusChoices.PAID, + discount_code=code, + ) + with self.assertRaises(HttpError): + code.calculate_discount(self.event, self.user) + + def test_usage_limit_per_user(self): + code = self._discount_code(usage_limit_per_user=1) + code.applicable_events.add(self.event) + Payment.objects.create( + user=self.user, + event=self.event, + base_amount=self.event.price, + amount=self.event.price, + discount_amount=0, + status=Payment.OrderStatusChoices.PENDING, + discount_code=code, + ) + with self.assertRaises(HttpError): + code.calculate_discount(self.event, self.user) + + def test_final_price_below_min_post_discount(self): + event = self._create_event(price=15000) + code = self._discount_code(value=80) + code.applicable_events.add(event) + with self.assertRaises(HttpError): + code.calculate_discount(event, self.user) + + def test_fixed_discount_type(self): + code = self._discount_code(type=DiscountCode.Type.FIXED, value=5000) + code.applicable_events.add(self.event) + final, disc = code.calculate_discount(self.event, self.user) + self.assertEqual(disc, 5000) + self.assertEqual(final, self.event.price - 5000) + + +class PaymentModelAndResourceTests(TestCase, PaymentTestMixin): + def setUp(self): + self.event = self._create_event() + self.user = self._create_user(is_email_verified=True) + + def test_payment_clean_validates_amount(self): + payment = Payment( + user=self.user, + event=self.event, + base_amount=1000, + amount=500, + discount_amount=400, + status=Payment.OrderStatusChoices.INIT, + ) + with self.assertRaises(ValidationError): + payment.full_clean() + + def test_payment_resource_defers_user_event(self): + payment = Payment.objects.create( + user=self.user, + event=self.event, + base_amount=1000, + amount=1000, + discount_amount=0, + status=Payment.OrderStatusChoices.INIT, + ) + resource = PaymentResource() + user_cell = resource.fields["user"].widget.clean(self.user.username, None) + self.assertEqual(user_cell, self.user) + event_cell = resource.fields["event"].widget.clean(self.event.title, None) + self.assertEqual(event_cell, self.event) + + def test_discount_resource_expands_events(self): + resource = DiscountResource() + widget = resource.fields["event"].widget + self.assertEqual(widget.separator, "||") + + +class DiscountCodeAdminTests(TestCase, PaymentTestMixin): + def setUp(self): + self.admin = DiscountCodeAdmin(DiscountCode, AdminSite()) + + def test_deactivate_codes_action(self): + code = self._discount_code() + queryset = DiscountCode.objects.filter(pk=code.pk) + request = SimpleNamespace(_messages=SimpleNamespace(add=lambda *args, **kwargs: None)) + self.admin.deactivate_codes(request, queryset) + code.refresh_from_db() + self.assertFalse(code.is_active) diff --git a/backend/tests/unit/test_users.py b/backend/tests/unit/test_users.py new file mode 100644 index 0000000..65e5839 --- /dev/null +++ b/backend/tests/unit/test_users.py @@ -0,0 +1,400 @@ +import uuid +from datetime import timedelta +from unittest import mock + +from django.db.models.signals import post_save +from django.test import SimpleTestCase, TestCase, override_settings +from django.utils import timezone + +from import_export.widgets import BooleanWidget + +from users.models import User, Major, University +from users.resources import UserResource +from users.signals import send_verification_email_on_registration +from users.tasks import ( + send_email_verified_success, + send_password_reset_email, + send_verification_email, +) + + +class UserFactoryMixin: + def _ensure_reference_objects(self): + if not hasattr(self, "_default_major"): + self._default_major, _ = Major.objects.get_or_create( + code="CS", + defaults={"name": "Computer Science"}, + ) + self._default_university, _ = University.objects.get_or_create( + code="UT", + defaults={"name": "University of Tehran"}, + ) + + def _resolve_major(self, value): + if value is None: + return None + if isinstance(value, Major): + return value + obj, _ = Major.objects.get_or_create(code=value, defaults={"name": value}) + return obj + + def _resolve_university(self, value): + if value is None: + return None + if isinstance(value, University): + return value + obj, _ = University.objects.get_or_create(code=value, defaults={"name": value}) + return obj + + def create_user(self, **extra_fields): + self._ensure_reference_objects() + unique = uuid.uuid4().hex + data = { + "email": f"user_{unique}@example.com", + "username": f"user_{unique[:10]}", + "first_name": "Test", + "last_name": "User", + } + password = extra_fields.pop("password", "StrongPass!123") + major = extra_fields.pop("major", self._default_major) + university = extra_fields.pop("university", self._default_university) + if isinstance(major, str): + major = self._resolve_major(major) + if isinstance(university, str): + university = self._resolve_university(university) + data.update(extra_fields) + data.setdefault("major", major) + data.setdefault("university", university) + return User.objects.create_user(password=password, **data) + + +class UserModelTests(UserFactoryMixin, TestCase): + def setUp(self): + super().setUp() + patcher = mock.patch("users.signals.send_verification_email.delay") + patcher.start() + self.addCleanup(patcher.stop) + + def test_str_returns_full_name_with_email(self): + # Arrange + user = self.create_user(first_name="Ada", last_name="Lovelace") + + # Act + result = str(user) + + # Assert + expected = f"{user.get_full_name()} ({user.email})" + self.assertEqual(result, expected) + + def test_get_full_name_handles_missing_names(self): + # Arrange + user = self.create_user(first_name="Grace", last_name="") + + # Act + result = user.get_full_name() + + # Assert + self.assertEqual(result, "Grace") + + def test_regenerate_verification_token_generates_new_value(self): + # Arrange + user = self.create_user() + original_token = user.email_verification_token + + # Act + user.regenerate_verification_token() + + # Assert + self.assertNotEqual(user.email_verification_token, original_token) + + def test_set_password_reset_token_assigns_future_expiry(self): + # Arrange + user = self.create_user() + frozen = timezone.now() + + # Act + with mock.patch("users.models.timezone.now", return_value=frozen): + user.set_password_reset_token() + + # Assert + self.assertIsNotNone(user.password_reset_token) + self.assertEqual( + user.password_reset_token_expires_at, + frozen + timedelta(hours=1), + ) + + def test_save_triggers_verified_task_on_state_change(self): + # Arrange + user = self.create_user() + + # Act + with mock.patch("users.tasks.send_email_verified_success.delay") as mock_delay: + user.is_email_verified = True + user.save() + + # Assert + mock_delay.assert_called_once_with(user.id) + + def test_save_skips_task_when_already_verified(self): + # Arrange + user = self.create_user(is_email_verified=True) + + # Act + with mock.patch("users.tasks.send_email_verified_success.delay") as mock_delay: + user.bio = "Updated bio" + user.save() + + # Assert + mock_delay.assert_not_called() + + +class UserSignalTests(TestCase): + def setUp(self): + super().setUp() + post_save.disconnect(send_verification_email_on_registration, sender=User) + self.addCleanup( + post_save.connect, + send_verification_email_on_registration, + User, + False, + ) + + @override_settings(FRONTEND_ROOT="https://frontend.example/") + @mock.patch("users.signals.send_verification_email.delay") + @mock.patch("users.signals.uuid.uuid4") + def test_signal_sets_username_timestamp_and_dispatches_email( + self, + mock_uuid, + mock_delay, + ): + # Arrange + fake_uuid = uuid.UUID("12345678-1234-5678-1234-567812345678") + mock_uuid.return_value = fake_uuid + fake_now = timezone.now() + user = User.objects.create( + email="new.user@example.com", + username="", + password="pass", + is_email_verified=False, + ) + + # Act + with mock.patch("users.signals.timezone.now", return_value=fake_now): + send_verification_email_on_registration(User, user, created=True) + + # Assert + user.refresh_from_db() + self.assertEqual(user.username, str(fake_uuid)[:10]) + self.assertEqual(user.email_verification_sent_at, fake_now) + expected_url = ( + f"https://frontend.example/verify-email/{user.email_verification_token}" + ) + mock_delay.assert_called_once_with(user.id, expected_url) + + @override_settings(FRONTEND_ROOT="https://frontend.example/") + @mock.patch("users.signals.send_verification_email.delay") + def test_signal_preserves_existing_username(self, mock_delay): + # Arrange + fake_now = timezone.now() + user = User.objects.create( + email="existing@example.com", + username="existing_name", + password="pass", + is_email_verified=False, + ) + + # Act + with mock.patch("users.signals.timezone.now", return_value=fake_now): + send_verification_email_on_registration(User, user, created=True) + + # Assert + user.refresh_from_db() + self.assertEqual(user.username, "existing_name") + self.assertEqual(user.email_verification_sent_at, fake_now) + mock_delay.assert_called_once() + + @mock.patch("users.signals.send_verification_email.delay") + def test_signal_skips_when_user_already_verified(self, mock_delay): + # Arrange + user = User.objects.create( + email="verified@example.com", + username="verified_user", + password="pass", + is_email_verified=True, + ) + + # Act + send_verification_email_on_registration(User, user, created=True) + + # Assert + self.assertIsNone(user.email_verification_sent_at) + mock_delay.assert_not_called() + + @mock.patch("users.signals.send_verification_email.delay") + def test_signal_skips_when_email_missing(self, mock_delay): + # Arrange + user = User.objects.create( + email="", + username="no_email", + password="pass", + is_email_verified=False, + ) + + # Act + send_verification_email_on_registration(User, user, created=True) + + # Assert + self.assertIsNone(user.email_verification_sent_at) + mock_delay.assert_not_called() + + @mock.patch("users.signals.send_verification_email.delay") + def test_signal_ignores_updates_to_existing_users(self, mock_delay): + # Arrange + user = User.objects.create( + email="existing-update@example.com", + username="existing_update", + password="pass", + is_email_verified=False, + ) + + # Act + send_verification_email_on_registration(User, user, created=False) + + # Assert + self.assertIsNone(user.email_verification_sent_at) + mock_delay.assert_not_called() + + +class UserTaskTests(UserFactoryMixin, TestCase): + def setUp(self): + super().setUp() + patcher = mock.patch("users.signals.send_verification_email.delay") + patcher.start() + self.addCleanup(patcher.stop) + + @override_settings(DEFAULT_FROM_EMAIL="no-reply@example.com") + @mock.patch("users.tasks.send_mail") + @mock.patch("users.tasks.render_to_string", return_value="

Hi

") + def test_send_verification_email_task_sends_expected_payload( + self, + mock_render, + mock_send_mail, + ): + # Arrange + user = self.create_user() + verification_url = "https://example.com/verify" + + # Act + result = send_verification_email.run(user.id, verification_url) + + # Assert + self.assertEqual(result, f"Verification email sent to {user.email}") + mock_render.assert_called_once_with( + "emails/verification_email.html", + {"user": user, "verification_url": verification_url}, + ) + kwargs = mock_send_mail.call_args.kwargs + self.assertEqual(kwargs["recipient_list"], [user.email]) + self.assertEqual(kwargs["from_email"], "no-reply@example.com") + self.assertEqual(kwargs["message"], "Hi") + + @override_settings(DEFAULT_FROM_EMAIL="support@example.com") + @mock.patch("users.tasks.send_mail") + @mock.patch("users.tasks.render_to_string", return_value="

Reset

") + def test_send_password_reset_email_task_uses_reset_template( + self, + mock_render, + mock_send_mail, + ): + # Arrange + user = self.create_user() + reset_url = "https://example.com/reset" + + # Act + result = send_password_reset_email.run(user.id, reset_url) + + # Assert + self.assertEqual(result, f"Password reset email sent to {user.email}") + mock_render.assert_called_once_with( + "emails/password_reset_email.html", + {"user": user, "reset_url": reset_url}, + ) + kwargs = mock_send_mail.call_args.kwargs + self.assertEqual(kwargs["recipient_list"], [user.email]) + self.assertEqual(kwargs["from_email"], "support@example.com") + self.assertEqual(kwargs["message"], "Reset") + + @override_settings( + DEFAULT_FROM_EMAIL="success@example.com", + FRONTEND_ROOT="https://frontend.example/", + ) + @mock.patch("users.tasks.send_mail") + @mock.patch("users.tasks.render_to_string", return_value="

Success

") + def test_send_email_verified_success_task_renders_success_template( + self, + mock_render, + mock_send_mail, + ): + # Arrange + user = self.create_user() + + # Act + result = send_email_verified_success.run(user.id) + + # Assert + self.assertEqual(result, f"verified success email sent to {user.email}") + mock_render.assert_called_once_with( + "emails/verification_success.html", + {"user": user, "home_url": "https://frontend.example/"}, + ) + kwargs = mock_send_mail.call_args.kwargs + self.assertEqual(kwargs["recipient_list"], [user.email]) + self.assertEqual(kwargs["from_email"], "success@example.com") + self.assertEqual(kwargs["message"], "Success") + + def test_send_verification_email_task_retries_on_lookup_error(self): + # Arrange + retry_patch = mock.patch.object( + send_verification_email, + "retry", + side_effect=RuntimeError("retry"), + ) + + # Act / Assert + with mock.patch( + "users.tasks.User.objects.get", + side_effect=ValueError("missing"), + ), retry_patch as mock_retry: + with self.assertRaises(RuntimeError): + send_verification_email.run(999, "https://example.com/verify") + + self.assertEqual(mock_retry.call_args.kwargs.get("countdown"), 60) + self.assertIsInstance(mock_retry.call_args.kwargs.get("exc"), ValueError) + + +class UserResourceTests(SimpleTestCase): + def test_boolean_fields_use_boolean_widget(self): + # Arrange + resource = UserResource() + + # Act + widgets = [ + resource.fields["is_staff"].widget, + resource.fields["is_superuser"].widget, + resource.fields["is_email_verified"].widget, + ] + + # Assert + for widget in widgets: + self.assertIsInstance(widget, BooleanWidget) + + def test_field_order_matches_meta_definition(self): + # Arrange + resource = UserResource() + + # Act + field_names = tuple(resource.fields.keys()) + + # Assert + self.assertEqual(resource._meta.export_order, resource._meta.fields) + self.assertSetEqual(set(field_names), set(resource._meta.fields)) diff --git a/backend/users/admin.py b/backend/users/admin.py new file mode 100644 index 0000000..c1bf9b8 --- /dev/null +++ b/backend/users/admin.py @@ -0,0 +1,122 @@ +from django import forms +from django.utils import timezone +from django.conf import settings +from django.contrib import admin, messages +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin + +from import_export.admin import ImportExportModelAdmin +from simplemde.widgets import SimpleMDEEditor + +from users.models import User, University, Major +from users.resources import UserResource +from users.tasks import send_verification_email +from utils.admin import SoftDeleteListFilter, BaseModelAdmin + + +class UserAdminForm(forms.ModelForm): + bio = forms.CharField(widget=SimpleMDEEditor(), required=False) + student_id = forms.CharField(required=False) + + class Meta: + model = User + fields = '__all__' + +@admin.register(User) +class UserAdmin(BaseUserAdmin, BaseModelAdmin, ImportExportModelAdmin): + form = UserAdminForm + resource_class = UserResource + list_display = ('email', 'username', 'university', 'is_email_verified', 'date_joined') + list_filter = ('is_email_verified', 'is_staff', 'year_of_study', SoftDeleteListFilter) + search_fields = ('email', 'username', 'student_id', 'first_name', 'last_name') + ordering = ('-date_joined',) + + fieldsets = ( + ('Auth Credentials', {'fields': ('username', 'email', 'password')}), + ('Personal info', { + 'fields': ('first_name', 'last_name', 'student_id', 'university', 'year_of_study', 'major', 'bio', 'profile_picture') + }), + ('Permissions', { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions',), + }), + ('Important dates', {'fields': ('last_login', 'date_joined')}), + + ('Email Verification', { + 'fields': ('is_email_verified', 'email_verification_token', 'email_verification_sent_at') + }), + ('Password Reset', { + 'fields': ('password_reset_token', 'password_reset_token_expires_at'), + 'classes': ('collapse',) + }), + ('Soft Delete', { + 'fields': ('is_deleted', 'deleted_at'), + 'classes': ('collapse',) + }), + ) + add_fieldsets = ( + ( + 'Step 1', + { + 'classes': ('wide',), + 'fields': ('email', 'student_id', 'password1', 'password2', 'usable_password'), + }, + ), + ) + + readonly_fields = ('email_verification_token', 'email_verification_sent_at', 'deleted_at', + 'password_reset_token', 'password_reset_token_expires_at') + + actions = BaseModelAdmin.actions + [ + 'verify_emails', + 'resend_verification_email', + ] + + @admin.action(description='Verify selected user emails') + def verify_emails(self, request, queryset): + queryset.update(is_email_verified=True) + self.message_user(request, f'Verified {queryset.count()} user emails.') + + @admin.action(description="Resend verification email") + def resend_verification_email(self, request, queryset): + qs = queryset.filter(is_email_verified=False).exclude(email__isnull=True).exclude(email="") + + total = queryset.count() + to_send = qs.count() + skipped = total - to_send + sent = failed = 0 + + for user in qs: + try: + user.regenerate_verification_token() + user.email_verification_sent_at = timezone.now() + user.save(update_fields=["email_verification_sent_at"]) + + verification_url = f"{settings.FRONTEND_ROOT}verify-email/{user.email_verification_token}" + send_verification_email.delay(user.id, verification_url) + sent += 1 + except Exception as exc: + failed += 1 + + if sent: + self.message_user(request, f"ایمیل تأیید برای {sent} کاربر ارسال شد.", level=messages.SUCCESS) + if skipped: + self.message_user( + request, + f"{skipped} کاربر کنار گذاشته شدند (یا قبلاً تأیید شده‌اند یا ایمیل ندارند).", + level=messages.WARNING, + ) + if failed: + self.message_user(request, f"ارسال برای {failed} کاربر با خطا مواجه شد.", level=messages.ERROR) + + +@admin.register(University) +class UniversityAdmin(BaseModelAdmin): + list_display = ('name', 'code', 'is_active', 'created_at') + list_filter = ('is_active', SoftDeleteListFilter) + search_fields = ('name', 'code') + + +@admin.register(Major) +class MajorAdmin(BaseModelAdmin): + list_display = ('name', 'code', 'is_active', 'created_at') + list_filter = ('is_active', SoftDeleteListFilter) + search_fields = ('name', 'code') diff --git a/backend/users/apps.py b/backend/users/apps.py new file mode 100644 index 0000000..2f741d5 --- /dev/null +++ b/backend/users/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'users' + + def ready(self): + import users.signals diff --git a/backend/users/fixtures/agile.json b/backend/users/fixtures/agile.json new file mode 100644 index 0000000..d3104f8 --- /dev/null +++ b/backend/users/fixtures/agile.json @@ -0,0 +1,48 @@ +[ + {"model":"users.user","fields":{"username":"u1403020111029","email":"pending-1403020111029@noemail.local","first_name":"پوریا","last_name":"شامخی","student_id":"1403020111029","year_of_study":1403,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1400020111002","email":"pending-1400020111002@noemail.local","first_name":"سمانه","last_name":"جباری","student_id":"1400020111002","year_of_study":1400,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u990201110035","email":"pending-990201110035@noemail.local","first_name":"سید علی","last_name":"حجتی مقدم","student_id":"990201110035","year_of_study":1399,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u990201200032","email":"pending-990201200032@noemail.local","first_name":"مهدی","last_name":"خدیوی سرشت","student_id":"990201200032","year_of_study":1399,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1403020111026","email":"pending-1403020111026@noemail.local","first_name":"امیر سجاد","last_name":"حیدری","student_id":"1403020111026","year_of_study":1403,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1403020111037","email":"pending-1403020111037@noemail.local","first_name":"امیرکیان","last_name":"رادپور","student_id":"1403020111037","year_of_study":1403,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1401020120011","email":"pending-1401020120011@noemail.local","first_name":"شیما","last_name":"گندم‌کار","student_id":"1401020120011","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1401020120024","email":"pending-1401020120024@noemail.local","first_name":"رضا","last_name":"سالمی‌درگاهی","student_id":"1401020120024","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1401020120102","email":"pending-1401020120102@noemail.local","first_name":"امیرمحمد","last_name":"نیک‌کار","student_id":"1401020120102","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1401020120028","email":"pending-1401020120028@noemail.local","first_name":"امیرمحمد","last_name":"کیان‌فر","student_id":"1401020120028","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1401020120035","email":"pending-1401020120035@noemail.local","first_name":"رژان","last_name":"پناهی‌پور","student_id":"1401020120035","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1400020111032","email":"pending-1400020111032@noemail.local","first_name":"مریم","last_name":"صفری","student_id":"1400020111032","year_of_study":1400,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1400020111014","email":"pending-1400020111014@noemail.local","first_name":"علیرضا","last_name":"رحیمی","student_id":"1400020111014","year_of_study":1400,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u992818200","email":"pending-992818200@noemail.local","first_name":"مریم","last_name":"مسلمی دوران محله","student_id":"992818200","year_of_study":1400,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1400020111022","email":"pending-1400020111022@noemail.local","first_name":"امیرمحمد","last_name":"خیراندیش","student_id":"1400020111022","year_of_study":1400,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1400020111029","email":"pending-1400020111029@noemail.local","first_name":"امیرحسین","last_name":"حسن‌پور","student_id":"1400020111029","year_of_study":1400,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u990201201007","email":"pending-990201201007@noemail.local","first_name":"امیررضا","last_name":"اخلاقی","student_id":"990201201007","year_of_study":1399,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1403020111006","email":"pending-1403020111006@noemail.local","first_name":"سینا","last_name":"زمان‌پور","student_id":"1403020111006","year_of_study":1403,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1403020130021","email":"pending-1403020130021@noemail.local","first_name":"سبحان","last_name":"آسوده جلالی","student_id":"1403020130021","year_of_study":1403,"major":null,"university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1403012268121","email":"pending-1403012268121@noemail.local","first_name":"فربد","last_name":"خلیلی خوشه مهر","student_id":"1403012268121","year_of_study":1403,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u03111129302057","email":"pending-03111129302057@noemail.local","first_name":"محمد مهدی","last_name":"جباری","student_id":"03111129302057","year_of_study":1403,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1403020121009","email":"pending-1403020121009@noemail.local","first_name":"امیرحسین","last_name":"امین‌پور","student_id":"1403020121009","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1403020121013","email":"pending-1403020121013@noemail.local","first_name":"عرشیا","last_name":"عرشی","student_id":"1403020121013","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1403020121023","email":"pending-1403020121023@noemail.local","first_name":"طاها","last_name":"محیط مافی","student_id":"1403020121023","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"uidx28","email":"pending-idx28@noemail.local","first_name":"مهدی","last_name":"منصورپور","student_id":null,"year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1403020121007","email":"pending-1403020121007@noemail.local","first_name":"سید محمدرضا","last_name":"حسین‌نیان","student_id":"1403020121007","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1403020121001","email":"pending-1403020121001@noemail.local","first_name":"محمود","last_name":"یاسری","student_id":"1403020121001","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1401020120039","email":"pending-1401020120039@noemail.local","first_name":"ارشاد","last_name":"ایزدی","student_id":"1401020120039","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1401020120002","email":"pending-1401020120002@noemail.local","first_name":"دلناز","last_name":"محمودی","student_id":"1401020120002","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1403020121018","email":"pending-1403020121018@noemail.local","first_name":"اروین","last_name":"نعمتی","student_id":"1403020121018","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1401020120149","email":"pending-1401020120149@noemail.local","first_name":"مائده","last_name":"حسرت قرانی","student_id":"1401020120149","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1401020120036","email":"pending-1401020120036@noemail.local","first_name":"شهریار","last_name":"اقاجانی","student_id":"1401020120036","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1403020121027","email":"pending-1403020121027@noemail.local","first_name":"عمید","last_name":"عباسی","student_id":"1403020121027","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u990201200016","email":"pending-990201200016@noemail.local","first_name":"مهدی","last_name":"دیداری","student_id":"990201200016","year_of_study":1399,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1401020120041","email":"pending-1401020120041@noemail.local","first_name":"حمید","last_name":"عباسی","student_id":"1401020120041","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1403020130022","email":"pending-1403020130022@noemail.local","first_name":"امیرمحمد","last_name":"نجفی","student_id":"1403020130022","year_of_study":1403,"major":null,"university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1401020111049","email":"pending-1401020111049@noemail.local","first_name":"علی","last_name":"رهگذر","student_id":"1401020111049","year_of_study":1401,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1401020120103","email":"pending-1401020120103@noemail.local","first_name":"یاسان","last_name":"حاج‌قلی‌زاده","student_id":"1401020120103","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"uidx45","email":"pending-idx45@noemail.local","first_name":"امیر","last_name":"دوستی ماسوله","student_id":null,"year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1401020120031","email":"pending-1401020120031@noemail.local","first_name":"امیررضا","last_name":"علیپور","student_id":"1401020120031","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u990201200036","email":"pending-990201200036@noemail.local","first_name":"مونا","last_name":"یحیی‌زاده واقفی","student_id":"990201200036","year_of_study":1399,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1401020120005","email":"pending-1401020120005@noemail.local","first_name":"بهار","last_name":"محمدی","student_id":"1401020120005","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1401020120026","email":"pending-1401020120026@noemail.local","first_name":"مطهره","last_name":"حق‌شناس","student_id":"1401020120026","year_of_study":1401,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u1403020121020","email":"pending-1403020121020@noemail.local","first_name":"محمد","last_name":"خلیلی‌مقدم ملامحله","student_id":"1403020121020","year_of_study":1403,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"u990201200027","email":"pending-990201200027@noemail.local","first_name":"مهراب","last_name":"گودرزی","student_id":"990201200027","year_of_study":1399,"major":"IE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}}, + {"model":"users.user","fields":{"username":"uidx52","email":"pending-idx52@noemail.local","first_name":"امیرمحمد","last_name":"چرختاب مقدم","student_id":null,"year_of_study":1403,"major":"CE","university":"GILAN","is_email_verified":false,"password":"pbkdf2_sha256$390000$guilan$2FFrsLkTODb20Jf7oxtxgXXL1z6uQY8iCJJ2OGANkKk=","is_active":true,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:00Z"}} +] diff --git a/backend/users/fixtures/users.json b/backend/users/fixtures/users.json new file mode 100644 index 0000000..77d41c1 --- /dev/null +++ b/backend/users/fixtures/users.json @@ -0,0 +1,244 @@ +[ + { + "model": "users.user", + "pk": 1, + "fields": { + "username": "admin", + "email": "admin@cs-association.ac.ir", + "first_name": "علی", + "last_name": "احمدی", + "student_id": "9812345001", + "year_of_study": 4, + "major": "مهندسی کامپیوتر", + "bio": "رئیس انجمن علمی مهندسی کامپیوتر دانشگاه", + "is_email_verified": true, + "email_verification_token": "550e8400-e29b-41d4-a716-446655440001", + "is_staff": true, + "is_superuser": true, + "password": "pbkdf2_sha256$600000$test$test", + "created_at": "2024-01-01T10:00:00Z", + "updated_at": "2024-01-01T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "users.user", + "pk": 2, + "fields": { + "username": "sara_mohammadi", + "email": "sara.mohammadi@student.ac.ir", + "first_name": "سارا", + "last_name": "محمدی", + "student_id": "9912345002", + "year_of_study": 3, + "major": "مهندسی کامپیوتر", + "bio": "نایب رئیس انجمن و مسئول رویدادها", + "is_email_verified": true, + "email_verification_token": "550e8400-e29b-41d4-a716-446655440002", + "password": "pbkdf2_sha256$600000$test$test", + "created_at": "2024-01-02T10:00:00Z", + "updated_at": "2024-01-02T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "users.user", + "pk": 3, + "fields": { + "username": "reza_karimi", + "email": "reza.karimi@student.ac.ir", + "first_name": "رضا", + "last_name": "کریمی", + "student_id": "9912345003", + "year_of_study": 2, + "major": "مهندسی کامپیوتر", + "bio": "علاقه‌مند به هوش مصنوعی و یادگیری ماشین", + "is_email_verified": true, + "email_verification_token": "550e8400-e29b-41d4-a716-446655440003", + "password": "pbkdf2_sha256$600000$test$test", + "created_at": "2024-01-03T10:00:00Z", + "updated_at": "2024-01-03T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "users.user", + "pk": 4, + "fields": { + "username": "maryam_hosseini", + "email": "maryam.hosseini@student.ac.ir", + "first_name": "مریم", + "last_name": "حسینی", + "student_id": "0012345004", + "year_of_study": 1, + "major": "مهندسی کامپیوتر", + "bio": "دانشجوی سال اول و علاقه‌مند به برنامه‌نویسی وب", + "is_email_verified": true, + "email_verification_token": "550e8400-e29b-41d4-a716-446655440004", + "password": "pbkdf2_sha256$600000$test$test", + "created_at": "2024-01-04T10:00:00Z", + "updated_at": "2024-01-04T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "users.user", + "pk": 5, + "fields": { + "username": "hassan_zare", + "email": "hassan.zare@student.ac.ir", + "first_name": "حسن", + "last_name": "زارع", + "student_id": "9812345005", + "year_of_study": 4, + "major": "مهندسی کامپیوتر", + "bio": "مسئول روابط عمومی انجمن", + "is_email_verified": true, + "email_verification_token": "550e8400-e29b-41d4-a716-446655440005", + "password": "pbkdf2_sha256$600000$test$test", + "created_at": "2024-01-05T10:00:00Z", + "updated_at": "2024-01-05T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "users.user", + "pk": 6, + "fields": { + "username": "zahra_safari", + "email": "zahra.safari@student.ac.ir", + "first_name": "زهرا", + "last_name": "صفری", + "student_id": "9912345006", + "year_of_study": 3, + "major": "مهندسی کامپیوتر", + "bio": "علاقه‌مند به امنیت سایبری و شبکه", + "is_email_verified": true, + "email_verification_token": "550e8400-e29b-41d4-a716-446655440006", + "password": "pbkdf2_sha256$600000$test$test", + "created_at": "2024-01-06T10:00:00Z", + "updated_at": "2024-01-06T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "users.user", + "pk": 7, + "fields": { + "username": "mohammad_rahmani", + "email": "mohammad.rahmani@student.ac.ir", + "first_name": "محمد", + "last_name": "رحمانی", + "student_id": "0012345007", + "year_of_study": 1, + "major": "مهندسی کامپیوتر", + "bio": "دانشجوی جدید الورود", + "is_email_verified": false, + "email_verification_token": "550e8400-e29b-41d4-a716-446655440007", + "password": "pbkdf2_sha256$600000$test$test", + "created_at": "2024-01-07T10:00:00Z", + "updated_at": "2024-01-07T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "users.user", + "pk": 8, + "fields": { + "username": "fateme_moradi", + "email": "fateme.moradi@student.ac.ir", + "first_name": "فاطمه", + "last_name": "مرادی", + "student_id": "9912345008", + "year_of_study": 2, + "major": "مهندسی کامپیوتر", + "bio": "علاقه‌مند به توسعه اپلیکیشن موبایل", + "is_email_verified": true, + "email_verification_token": "550e8400-e29b-41d4-a716-446655440008", + "password": "pbkdf2_sha256$600000$test$test", + "created_at": "2024-01-08T10:00:00Z", + "updated_at": "2024-01-08T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "users.user", + "pk": 9, + "fields": { + "username": "amir_ghorbani", + "email": "amir.ghorbani@student.ac.ir", + "first_name": "امیر", + "last_name": "قربانی", + "student_id": "9812345009", + "year_of_study": 4, + "major": "مهندسی کامپیوتر", + "bio": "مسئول فنی انجمن و توسعه‌دهنده وب‌سایت", + "is_email_verified": true, + "email_verification_token": "550e8400-e29b-41d4-a716-446655440009", + "password": "pbkdf2_sha256$600000$test$test", + "created_at": "2024-01-09T10:00:00Z", + "updated_at": "2024-01-09T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "users.user", + "pk": 10, + "fields": { + "username": "nasrin_jafari", + "email": "nasrin.jafari@student.ac.ir", + "first_name": "نسرین", + "last_name": "جعفری", + "student_id": "9912345010", + "year_of_study": 3, + "major": "مهندسی کامپیوتر", + "bio": "علاقه‌مند به علم داده و تحلیل داده", + "is_email_verified": true, + "email_verification_token": "550e8400-e29b-41d4-a716-446655440010", + "password": "pbkdf2_sha256$600000$test$test", + "created_at": "2024-01-10T10:00:00Z", + "updated_at": "2024-01-10T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "users.user", + "pk": 11, + "fields": { + "username": "mehdi_bagheri", + "email": "mehdi.bagheri@student.ac.ir", + "first_name": "مهدی", + "last_name": "باقری", + "student_id": "0012345011", + "year_of_study": 1, + "major": "مهندسی کامپیوتر", + "bio": "دانشجوی سال اول و علاقه‌مند به بازی‌سازی", + "is_email_verified": true, + "email_verification_token": "550e8400-e29b-41d4-a716-446655440011", + "password": "pbkdf2_sha256$600000$test$test", + "created_at": "2024-01-11T10:00:00Z", + "updated_at": "2024-01-11T10:00:00Z", + "is_deleted": false + } + }, + { + "model": "users.user", + "pk": 12, + "fields": { + "username": "leila_mousavi", + "email": "leila.mousavi@student.ac.ir", + "first_name": "لیلا", + "last_name": "موسوی", + "student_id": "9912345012", + "year_of_study": 2, + "major": "مهندسی کامپیوتر", + "bio": "علاقه‌مند به طراحی UI/UX", + "is_email_verified": true, + "email_verification_token": "550e8400-e29b-41d4-a716-446655440012", + "password": "pbkdf2_sha256$600000$test$test", + "created_at": "2024-01-12T10:00:00Z", + "updated_at": "2024-01-12T10:00:00Z", + "is_deleted": false + } + } +] diff --git a/backend/users/migrations/0001_initial.py b/backend/users/migrations/0001_initial.py new file mode 100644 index 0000000..0e913fe --- /dev/null +++ b/backend/users/migrations/0001_initial.py @@ -0,0 +1,60 @@ +# Generated by Django 5.2.5 on 2025-10-16 12:07 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('is_deleted', models.BooleanField(default=False)), + ('deleted_at', models.DateTimeField(blank=True, null=True)), + ('email', models.EmailField(max_length=254, unique=True)), + ('bio', models.TextField(blank=True, null=True)), + ('profile_picture', models.ImageField(blank=True, null=True, upload_to='profile_pictures/')), + ('student_id', models.CharField(max_length=20, null=True)), + ('year_of_study', models.IntegerField(blank=True, null=True)), + ('major', models.CharField(blank=True, choices=[('CE', 'مهندسی کامپیوتر'), ('CS', 'علوم کامپیوتر'), ('SE', 'مهندسی نرم\u200cافزار'), ('IT', 'فناوری اطلاعات'), ('AI', 'هوش مصنوعی و رباتیک'), ('DATA', 'علم داده'), ('EE', 'مهندسی برق'), ('ME', 'مهندسی مکانیک'), ('CIV', 'مهندسی عمران'), ('CHE', 'مهندسی شیمی'), ('IE', 'مهندسی صنایع'), ('MSE', 'مهندسی مواد و متالورژی'), ('BME', 'مهندسی پزشکی'), ('ARCH', 'معماری'), ('AERO', 'مهندسی هوافضا'), ('PET', 'مهندسی نفت'), ('MIN', 'مهندسی معدن'), ('ENV', 'مهندسی محیط\u200cزیست'), ('URP', 'برنامه\u200cریزی شهری و منطقه\u200cای'), ('MATH', 'ریاضیات'), ('STAT', 'آمار'), ('PHYS', 'فیزیک'), ('CHEM', 'شیمی'), ('BIO', 'زیست\u200cشناسی'), ('GEO', 'زمین\u200cشناسی'), ('MED', 'پزشکی'), ('DEN', 'دندان\u200cپزشکی'), ('PHARM', 'داروسازی'), ('NURS', 'پرستاری'), ('MID', 'مامایی'), ('LAB', 'علوم آزمایشگاهی'), ('RAD', 'رادیولوژی'), ('ANES', 'بیهوشی'), ('PUBH', 'بهداشت'), ('AGRI', 'کشاورزی (عمومی)'), ('HORT', 'باغبانی'), ('PLP', 'گیاه\u200cپزشکی'), ('SOIL', 'علوم خاک'), ('VET', 'دامپزشکی'), ('MGT', 'مدیریت'), ('ACC', 'حسابداری'), ('FIN', 'مالی'), ('ECO', 'اقتصاد'), ('BA', 'مدیریت بازرگانی'), ('LAW', 'حقوق'), ('POL', 'علوم سیاسی'), ('SOC', 'جامعه\u200cشناسی'), ('PSY', 'روان\u200cشناسی'), ('PHIL', 'فلسفه'), ('HIST', 'تاریخ'), ('GEOG', 'جغرافیا'), ('EDU', 'علوم تربیتی'), ('PEd', 'تربیت بدنی'), ('LIT_FA', 'زبان و ادبیات فارسی'), ('LIT_EN', 'زبان و ادبیات انگلیسی'), ('LIT_AR', 'زبان و ادبیات عربی'), ('TRAN_EN', 'مترجمی زبان انگلیسی'), ('ART', 'هنرهای تجسمی'), ('GRAPH', 'گرافیک'), ('MUSIC', 'موسیقی'), ('THEAT', 'نمایش و تئاتر')], max_length=16, null=True)), + ('university', models.CharField(blank=True, choices=[('UT', 'دانشگاه تهران'), ('AUT', 'دانشگاه صنعتی امیرکبیر'), ('SHARIF', 'دانشگاه صنعتی شریف'), ('SBU', 'دانشگاه شهید بهشتی'), ('IUST', 'دانشگاه علم و صنعت ایران'), ('KNTU', 'دانشگاه صنعتی خواجه\u200cنصیر'), ('MODARES', 'دانشگاه تربیت مدرس'), ('ALLAMEH', 'دانشگاه علامه طباطبایی'), ('KHARAZMI', 'دانشگاه خوارزمی'), ('ISFAHAN_UNI', 'دانشگاه اصفهان'), ('IUT', 'دانشگاه صنعتی اصفهان'), ('SHIRAZ_UNI', 'دانشگاه شیراز'), ('TABRIZ_UNI', 'دانشگاه تبریز'), ('FERDOWSI', 'دانشگاه فردوسی مشهد'), ('RAZI', 'دانشگاه رازی'), ('BUALI', 'دانشگاه بوعلی\u200cسینا'), ('KURDISTAN', 'دانشگاه کردستان'), ('YAZD_UNI', 'دانشگاه یزد'), ('KERMAN_UNI', 'دانشگاه شهید باهنر کرمان'), ('MAZANDARAN', 'دانشگاه مازندران'), ('GILAN', 'دانشگاه گیلان'), ('GOLESTAN', 'دانشگاه گلستان'), ('URMIA', 'دانشگاه ارومیه'), ('ZANJAN', 'دانشگاه زنجان'), ('ARDABIL', 'دانشگاه محقق اردبیلی'), ('ARAK_UNI', 'دانشگاه اراک'), ('SEMNAN', 'دانشگاه سمنان'), ('SHAHROOD', 'دانشگاه صنعتی شاهرود'), ('QOM_UNI', 'دانشگاه قم'), ('IKIU', 'دانشگاه بین\u200cالمللی امام خمینی قزوین'), ('MAL_ASHTAR', 'دانشگاه صنعتی مالک\u200cاشتر'), ('SAHAND', 'دانشگاه صنعتی سهند'), ('BABOL_NOSH', 'دانشگاه صنعتی نوشیروانی بابل'), ('TOLOU', 'دانشگاه تحصیلات تکمیلی صنعتی و فناوری پیشرفته کرمان'), ('TUMS', 'دانشگاه علوم پزشکی تهران'), ('SBMU_MED', 'دانشگاه علوم پزشکی شهید بهشتی'), ('IUMS_MED', 'دانشگاه علوم پزشکی ایران'), ('MUMS_MED', 'دانشگاه علوم پزشکی مشهد'), ('SUMS_MED', 'دانشگاه علوم پزشکی شیراز'), ('TBZ_MED', 'دانشگاه علوم پزشکی تبریز'), ('ISF_MED', 'دانشگاه علوم پزشکی اصفهان'), ('AJA_MED', 'دانشگاه علوم پزشکی ارتش'), ('AJUMS_MED', 'دانشگاه علوم پزشکی اهواز'), ('KUMS_MED', 'دانشگاه علوم پزشکی کرمانشاه'), ('KER_MED', 'دانشگاه علوم پزشکی کرمان'), ('IAU_TEH', 'دانشگاه آزاد اسلامی تهران'), ('IAU_SCIRES', 'دانشگاه آزاد اسلامی علوم و تحقیقات تهران'), ('IAU_MASH', 'دانشگاه آزاد اسلامی مشهد'), ('IAU_TBRZ', 'دانشگاه آزاد اسلامی تبریز'), ('IAU_SHIR', 'دانشگاه آزاد اسلامی شیراز'), ('IAU_ISF', 'دانشگاه آزاد اسلامی اصفهان (خوراسگان)'), ('IAU_RASHT', 'دانشگاه آزاد اسلامی رشت'), ('IAU_QAZ', 'دانشگاه آزاد اسلامی قزوین'), ('PNU_TEH', 'دانشگاه پیام نور تهران'), ('PNU_RAS', 'دانشگاه پیام نور رشت'), ('PNU_MASH', 'دانشگاه پیام نور مشهد'), ('PNU_TBRZ', 'دانشگاه پیام نور تبریز'), ('UAST_TEH', 'دانشگاه علمی-کاربردی تهران'), ('UAST_GIL', 'دانشگاه علمی-کاربردی گیلان'), ('TVU_TEH', 'دانشگاه فنی و حرفه\u200cای تهران'), ('TVU_GIL', 'دانشگاه فنی و حرفه\u200cای گیلان'), ('RAJAEI', 'دانشگاه تربیت دبیر شهید رجایی'), ('IMAM_SADEQ', 'دانشگاه امام صادق (ع)'), ('ART_TEH', 'دانشگاه هنر'), ('TEH_MARK', 'دانشگاه علامه محدث نوری/علامه طباطبایی (در صورت نیاز اصلاح کنید)')], max_length=16, null=True)), + ('is_email_verified', models.BooleanField(default=False)), + ('email_verification_token', models.UUIDField(default=uuid.uuid4, unique=True)), + ('email_verification_sent_at', models.DateTimeField(blank=True, null=True)), + ('password_reset_token', models.UUIDField(blank=True, null=True, unique=True)), + ('password_reset_token_expires_at', models.DateTimeField(blank=True, null=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'User', + 'verbose_name_plural': 'Users', + 'db_table': 'users', + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/backend/users/migrations/0002_alter_user_university.py b/backend/users/migrations/0002_alter_user_university.py new file mode 100644 index 0000000..74c28a6 --- /dev/null +++ b/backend/users/migrations/0002_alter_user_university.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-10-18 10:02 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='university', + field=models.CharField(blank=True, choices=[('UT', 'دانشگاه تهران'), ('AUT', 'دانشگاه صنعتی امیرکبیر'), ('SHARIF', 'دانشگاه صنعتی شریف'), ('SBU', 'دانشگاه شهید بهشتی'), ('IUST', 'دانشگاه علم و صنعت ایران'), ('KNTU', 'دانشگاه صنعتی خواجه\u200cنصیر'), ('MODARES', 'دانشگاه تربیت مدرس'), ('ALLAMEH', 'دانشگاه علامه طباطبایی'), ('KHARAZMI', 'دانشگاه خوارزمی'), ('ISFAHAN_UNI', 'دانشگاه اصفهان'), ('IUT', 'دانشگاه صنعتی اصفهان'), ('SHIRAZ_UNI', 'دانشگاه شیراز'), ('TABRIZ_UNI', 'دانشگاه تبریز'), ('FERDOWSI', 'دانشگاه فردوسی مشهد'), ('RAZI', 'دانشگاه رازی'), ('BUALI', 'دانشگاه بوعلی\u200cسینا'), ('KURDISTAN', 'دانشگاه کردستان'), ('YAZD_UNI', 'دانشگاه یزد'), ('KERMAN_UNI', 'دانشگاه شهید باهنر کرمان'), ('MAZANDARAN', 'دانشگاه مازندران'), ('GILAN', 'دانشگاه گیلان'), ('GOLESTAN', 'دانشگاه گلستان'), ('URMIA', 'دانشگاه ارومیه'), ('ZANJAN', 'دانشگاه زنجان'), ('ARDABIL', 'دانشگاه محقق اردبیلی'), ('ARAK_UNI', 'دانشگاه اراک'), ('SEMNAN', 'دانشگاه سمنان'), ('SHAHROOD', 'دانشگاه صنعتی شاهرود'), ('QOM_UNI', 'دانشگاه قم'), ('IKIU', 'دانشگاه بین\u200cالمللی امام خمینی قزوین'), ('MAL_ASHTAR', 'دانشگاه صنعتی مالک\u200cاشتر'), ('SAHAND', 'دانشگاه صنعتی سهند'), ('BABOL_NOSH', 'دانشگاه صنعتی نوشیروانی بابل'), ('TOLOU', 'دانشگاه تحصیلات تکمیلی صنعتی و فناوری پیشرفته کرمان'), ('TUMS', 'دانشگاه علوم پزشکی تهران'), ('SBMU_MED', 'دانشگاه علوم پزشکی شهید بهشتی'), ('IUMS_MED', 'دانشگاه علوم پزشکی ایران'), ('MUMS_MED', 'دانشگاه علوم پزشکی مشهد'), ('SUMS_MED', 'دانشگاه علوم پزشکی شیراز'), ('TBZ_MED', 'دانشگاه علوم پزشکی تبریز'), ('ISF_MED', 'دانشگاه علوم پزشکی اصفهان'), ('AJA_MED', 'دانشگاه علوم پزشکی ارتش'), ('AJUMS_MED', 'دانشگاه علوم پزشکی اهواز'), ('KUMS_MED', 'دانشگاه علوم پزشکی کرمانشاه'), ('KER_MED', 'دانشگاه علوم پزشکی کرمان'), ('IAU_TEH', 'دانشگاه آزاد اسلامی تهران'), ('IAU_SCIRES', 'دانشگاه آزاد اسلامی علوم و تحقیقات تهران'), ('IAU_MASH', 'دانشگاه آزاد اسلامی مشهد'), ('IAU_TBRZ', 'دانشگاه آزاد اسلامی تبریز'), ('IAU_SHIR', 'دانشگاه آزاد اسلامی شیراز'), ('IAU_ISF', 'دانشگاه آزاد اسلامی اصفهان'), ('IAU_RASHT', 'دانشگاه آزاد اسلامی رشت'), ('IAU_LAHIJAN', 'دانشگاه آزاد اسلامی لاهیجان'), ('IAU_QAZ', 'دانشگاه آزاد اسلامی قزوین'), ('PNU_TEH', 'دانشگاه پیام نور تهران'), ('PNU_RAS', 'دانشگاه پیام نور رشت'), ('PNU_MASH', 'دانشگاه پیام نور مشهد'), ('PNU_TBRZ', 'دانشگاه پیام نور تبریز'), ('UAST_TEH', 'دانشگاه علمی-کاربردی تهران'), ('UAST_GIL', 'دانشگاه علمی-کاربردی گیلان'), ('TVU_TEH', 'دانشگاه فنی و حرفه\u200cای تهران'), ('TVU_GIL', 'دانشگاه فنی و حرفه\u200cای گیلان'), ('RAJAEI', 'دانشگاه تربیت دبیر شهید رجایی'), ('IMAM_SADEQ', 'دانشگاه امام صادق (ع)'), ('ART_TEH', 'دانشگاه هنر')], max_length=127, null=True), + ), + ] diff --git a/backend/users/migrations/0003_alter_user_university.py b/backend/users/migrations/0003_alter_user_university.py new file mode 100644 index 0000000..2e40608 --- /dev/null +++ b/backend/users/migrations/0003_alter_user_university.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2025-10-18 16:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('users', '0002_alter_user_university'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='university', + field=models.CharField(blank=True, choices=[('GILAN', 'دانشگاه گیلان'), ('UT', 'دانشگاه تهران'), ('AUT', 'دانشگاه صنعتی امیرکبیر'), ('SHARIF', 'دانشگاه صنعتی شریف'), ('SBU', 'دانشگاه شهید بهشتی'), ('IUST', 'دانشگاه علم و صنعت ایران'), ('KNTU', 'دانشگاه صنعتی خواجه\u200cنصیر'), ('MODARES', 'دانشگاه تربیت مدرس'), ('ALLAMEH', 'دانشگاه علامه طباطبایی'), ('KHARAZMI', 'دانشگاه خوارزمی'), ('ISFAHAN_UNI', 'دانشگاه اصفهان'), ('IUT', 'دانشگاه صنعتی اصفهان'), ('SHIRAZ_UNI', 'دانشگاه شیراز'), ('TABRIZ_UNI', 'دانشگاه تبریز'), ('FERDOWSI', 'دانشگاه فردوسی مشهد'), ('RAZI', 'دانشگاه رازی'), ('BUALI', 'دانشگاه بوعلی\u200cسینا'), ('KURDISTAN', 'دانشگاه کردستان'), ('YAZD_UNI', 'دانشگاه یزد'), ('KERMAN_UNI', 'دانشگاه شهید باهنر کرمان'), ('MAZANDARAN', 'دانشگاه مازندران'), ('GOLESTAN', 'دانشگاه گلستان'), ('URMIA', 'دانشگاه ارومیه'), ('ZANJAN', 'دانشگاه زنجان'), ('ARDABIL', 'دانشگاه محقق اردبیلی'), ('ARAK_UNI', 'دانشگاه اراک'), ('SEMNAN', 'دانشگاه سمنان'), ('SHAHROOD', 'دانشگاه صنعتی شاهرود'), ('QOM_UNI', 'دانشگاه قم'), ('IKIU', 'دانشگاه بین\u200cالمللی امام خمینی قزوین'), ('MAL_ASHTAR', 'دانشگاه صنعتی مالک\u200cاشتر'), ('SAHAND', 'دانشگاه صنعتی سهند'), ('BABOL_NOSH', 'دانشگاه صنعتی نوشیروانی بابل'), ('ALZAHRA', 'دانشگاه الزهرا'), ('TAFRESH', 'دانشگاه تفرش'), ('JAHROM', 'دانشگاه جهرم'), ('HAKIM_SABZ', 'دانشگاه حکیم سبزواری'), ('PERSIAN_GULF', 'دانشگاه خلیج فارس'), ('DAMGHAN', 'دانشگاه دامغان'), ('ILAM', 'دانشگاه ایلام'), ('BOJNORD', 'دانشگاه بجنورد'), ('KASHAN', 'دانشگاه کاشان'), ('LORESTAN', 'دانشگاه لرستان'), ('MARAGHEH', 'دانشگاه مراغه'), ('MALAYER', 'دانشگاه ملایر'), ('NEYSHABUR', 'دانشگاه نیشابور'), ('HORMOZGAN', 'دانشگاه هرمزگان'), ('HONAR', 'دانشگاه هنر'), ('TUMS', 'دانشگاه علوم پزشکی تهران'), ('SBMU_MED', 'دانشگاه علوم پزشکی شهید بهشتی'), ('IUMS_MED', 'دانشگاه علوم پزشکی ایران'), ('MUMS_MED', 'دانشگاه علوم پزشکی مشهد'), ('SUMS_MED', 'دانشگاه علوم پزشکی شیراز'), ('TBZ_MED', 'دانشگاه علوم پزشکی تبریز'), ('ISF_MED', 'دانشگاه علوم پزشکی اصفهان'), ('AJUMS_MED', 'دانشگاه علوم پزشکی اهواز'), ('AJA_MED', 'دانشگاه علوم پزشکی ارتش'), ('KUMS_MED', 'دانشگاه علوم پزشکی کرمانشاه'), ('KER_MED', 'دانشگاه علوم پزشکی کرمان'), ('MED_QOM', 'دانشگاه علوم پزشکی قم'), ('MED_QAZVIN', 'دانشگاه علوم پزشکی قزوین'), ('MED_ALBORZ', 'دانشگاه علوم پزشکی البرز'), ('MED_ARAK', 'دانشگاه علوم پزشکی اراک'), ('MED_ZANJAN', 'دانشگاه علوم پزشکی زنجان'), ('MED_MAZANDARAN', 'دانشگاه علوم پزشکی مازندران'), ('MED_BABOL', 'دانشگاه علوم پزشکی بابل'), ('MED_GOLESTAN', 'دانشگاه علوم پزشکی گلستان'), ('MED_GILAN', 'دانشگاه علوم پزشکی گیلان'), ('MED_HORMOZGAN', 'دانشگاه علوم پزشکی هرمزگان'), ('MED_BUSHEHR', 'دانشگاه علوم پزشکی بوشهر'), ('MED_BIRJAND', 'دانشگاه علوم پزشکی بیرجند'), ('MED_BOJNORD', 'دانشگاه علوم پزشکی خراسان شمالی (بجنورد)'), ('MED_SABZEVAR', 'دانشگاه علوم پزشکی سبزوار'), ('MED_NEYSHABUR', 'دانشگاه علوم پزشکی نیشابور'), ('MED_GONABAD', 'دانشگاه علوم پزشکی گناباد'), ('MED_SHAHROUD', 'دانشگاه علوم پزشکی شاهرود'), ('MED_SEMNAN', 'دانشگاه علوم پزشکی سمنان'), ('MED_YAZD', 'دانشگاه علوم پزشکی یزد'), ('MED_URMIA', 'دانشگاه علوم پزشکی ارومیه'), ('MED_ARDABIL', 'دانشگاه علوم پزشکی اردبیل'), ('MED_HAMEDAN', 'دانشگاه علوم پزشکی همدان'), ('MED_LARESTAN', 'دانشکده علوم پزشکی لارستان'), ('MED_FASA', 'دانشگاه علوم پزشکی فسا'), ('MED_JAHROM', 'دانشگاه علوم پزشکی جهرم'), ('MED_KASHAN', 'دانشگاه علوم پزشکی کاشان'), ('MED_ILAM', 'دانشگاه علوم پزشکی ایلام'), ('MED_LORESTAN', 'دانشگاه علوم پزشکی لرستان'), ('MED_KHUZESTAN', 'دانشگاه علوم پزشکی دزفول/شوشتر (استان خوزستان)'), ('IAU_TEH_CENTRAL', 'دانشگاه آزاد اسلامی واحد تهران مرکزی'), ('IAU_TEH_NORTH', 'دانشگاه آزاد اسلامی واحد تهران شمال'), ('IAU_TEH_SOUTH', 'دانشگاه آزاد اسلامی واحد تهران جنوب'), ('IAU_TEH_WEST', 'دانشگاه آزاد اسلامی واحد تهران غرب'), ('IAU_TEH_EAST', 'دانشگاه آزاد اسلامی واحد تهران شرق'), ('IAU_SRT_TEHRAN', 'دانشگاه آزاد اسلامی واحد علوم و تحقیقات تهران'), ('IAU_QAZVIN', 'دانشگاه آزاد اسلامی قزوین'), ('IAU_NAJAFABAD', 'دانشگاه آزاد اسلامی نجف\u200cآباد'), ('IAU_MASHHAD', 'دانشگاه آزاد اسلامی مشهد'), ('IAU_TABRIZ', 'دانشگاه آزاد اسلامی تبریز'), ('IAU_SHIRAZ', 'دانشگاه آزاد اسلامی شیراز'), ('IAU_ISFAHAN', 'دانشگاه آزاد اسلامی اصفهان (خوراسگان)'), ('IAU_KARAJ', 'دانشگاه آزاد اسلامی کرج'), ('IAU_QOM', 'دانشگاه آزاد اسلامی قم'), ('IAU_RASHT', 'دانشگاه آزاد اسلامی رشت'), ('IAU_LAHIJAN', 'دانشگاه آزاد اسلامی لاهیجان'), ('IAU_SARI', 'دانشگاه آزاد اسلامی ساری'), ('IAU_YAZD', 'دانشگاه آزاد اسلامی یزد'), ('IAU_KERMAN', 'دانشگاه آزاد اسلامی کرمان'), ('IAU_BANDARABBAS', 'دانشگاه آزاد اسلامی بندرعباس'), ('IAU_BUSHEHR', 'دانشگاه آزاد اسلامی بوشهر'), ('IAU_AHVAZ', 'دانشگاه آزاد اسلامی اهواز'), ('IAU_KHORRAMABAD', 'دانشگاه آزاد اسلامی خرم\u200cآباد'), ('IAU_SANANDAJ', 'دانشگاه آزاد اسلامی سنندج'), ('IAU_HAMEDAN', 'دانشگاه آزاد اسلامی همدان'), ('IAU_ARAK', 'دانشگاه آزاد اسلامی اراک'), ('IAU_URMIA', 'دانشگاه آزاد اسلامی ارومیه'), ('IAU_ZANJAN', 'دانشگاه آزاد اسلامی زنجان'), ('IAU_BIRJAND', 'دانشگاه آزاد اسلامی بیرجند'), ('IAU_BOJNORD', 'دانشگاه آزاد اسلامی بجنورد'), ('IAU_SEMNAN', 'دانشگاه آزاد اسلامی سمنان'), ('IAU_GORGAN', 'دانشگاه آزاد اسلامی گرگان'), ('IAU_MARVDASHT', 'دانشگاه آزاد اسلامی مرودشت'), ('IAU_KISH_INTL', 'دانشگاه آزاد اسلامی بین\u200cالملل کیش'), ('IAU_QESHM_INTL', 'دانشگاه آزاد اسلامی قشم (بین\u200cالملل)'), ('PNU_EAST_AZERBAIJAN', 'دانشگاه پیام نور آذربایجان شرقی'), ('PNU_WEST_AZERBAIJAN', 'دانشگاه پیام نور آذربایجان غربی'), ('PNU_ARDABIL', 'دانشگاه پیام نور اردبیل'), ('PNU_ISFAHAN', 'دانشگاه پیام نور اصفهان'), ('PNU_ALBORZ', 'دانشگاه پیام نور البرز'), ('PNU_ILAM', 'دانشگاه پیام نور ایلام'), ('PNU_BUSHEHR', 'دانشگاه پیام نور بوشهر'), ('PNU_TEHRAN', 'دانشگاه پیام نور تهران'), ('PNU_CH_BAKHTIARI', 'دانشگاه پیام نور چهارمحال و بختیاری'), ('PNU_SOUTH_KHORASAN', 'دانشگاه پیام نور خراسان جنوبی'), ('PNU_RAZAVI_KHORASAN', 'دانشگاه پیام نور خراسان رضوی'), ('PNU_NORTH_KHORASAN', 'دانشگاه پیام نور خراسان شمالی'), ('PNU_KHUZESTAN', 'دانشگاه پیام نور خوزستان'), ('PNU_ZANJAN', 'دانشگاه پیام نور زنجان'), ('PNU_SEMNAN', 'دانشگاه پیام نور سمنان'), ('PNU_SISTAN_BALUCH', 'دانشگاه پیام نور سیستان و بلوچستان'), ('PNU_FARS', 'دانشگاه پیام نور فارس'), ('PNU_QAZVIN', 'دانشگاه پیام نور قزوین'), ('PNU_QOM', 'دانشگاه پیام نور قم'), ('PNU_KURDISTAN', 'دانشگاه پیام نور کردستان'), ('PNU_KERMAN', 'دانشگاه پیام نور کرمان'), ('PNU_KERMANSHAH', 'دانشگاه پیام نور کرمانشاه'), ('PNU_KOHGILUYEH', 'دانشگاه پیام نور کهگیلویه و بویراحمد'), ('PNU_GOLESTAN', 'دانشگاه پیام نور گلستان'), ('PNU_GILAN', 'دانشگاه پیام نور گیلان'), ('PNU_LORESTAN', 'دانشگاه پیام نور لرستان'), ('PNU_MAZANDARAN', 'دانشگاه پیام نور مازندران'), ('PNU_MARKAZI', 'دانشگاه پیام نور مرکزی'), ('PNU_HORMOZGAN', 'دانشگاه پیام نور هرمزگان'), ('PNU_HAMEDAN', 'دانشگاه پیام نور همدان'), ('PNU_YAZD', 'دانشگاه پیام نور یزد'), ('UAST_EAST_AZERBAIJAN', 'دانشگاه جامع علمی کاربردی آذربایجان شرقی'), ('UAST_WEST_AZERBAIJAN', 'دانشگاه جامع علمی کاربردی آذربایجان غربی'), ('UAST_ARDABIL', 'دانشگاه جامع علمی کاربردی اردبیل'), ('UAST_ISFAHAN', 'دانشگاه جامع علمی کاربردی اصفهان'), ('UAST_ALBORZ', 'دانشگاه جامع علمی کاربردی البرز'), ('UAST_ILAM', 'دانشگاه جامع علمی کاربردی ایلام'), ('UAST_BUSHEHR', 'دانشگاه جامع علمی کاربردی بوشهر'), ('UAST_TEHRAN', 'دانشگاه جامع علمی کاربردی تهران'), ('UAST_CH_BAKHTIARI', 'دانشگاه جامع علمی کاربردی چهارمحال و بختیاری'), ('UAST_SOUTH_KHORASAN', 'دانشگاه جامع علمی کاربردی خراسان جنوبی'), ('UAST_RAZAVI_KHORASAN', 'دانشگاه جامع علمی کاربردی خراسان رضوی'), ('UAST_NORTH_KHORASAN', 'دانشگاه جامع علمی کاربردی خراسان شمالی'), ('UAST_KHUZESTAN', 'دانشگاه جامع علمی کاربردی خوزستان'), ('UAST_ZANJAN', 'دانشگاه جامع علمی کاربردی زنجان'), ('UAST_SEMNAN', 'دانشگاه جامع علمی کاربردی سمنان'), ('UAST_SISTAN_BALUCH', 'دانشگاه جامع علمی کاربردی سیستان و بلوچستان'), ('UAST_FARS', 'دانشگاه جامع علمی کاربردی فارس'), ('UAST_QAZVIN', 'دانشگاه جامع علمی کاربردی قزوین'), ('UAST_QOM', 'دانشگاه جامع علمی کاربردی قم'), ('UAST_KURDISTAN', 'دانشگاه جامع علمی کاربردی کردستان'), ('UAST_KERMAN', 'دانشگاه جامع علمی کاربردی کرمان'), ('UAST_KERMANSHAH', 'دانشگاه جامع علمی کاربردی کرمانشاه'), ('UAST_KOHGILUYEH', 'دانشگاه جامع علمی کاربردی کهگیلویه و بویراحمد'), ('UAST_GOLESTAN', 'دانشگاه جامع علمی کاربردی گلستان'), ('UAST_GILAN', 'دانشگاه جامع علمی کاربردی گیلان'), ('UAST_LORESTAN', 'دانشگاه جامع علمی کاربردی لرستان'), ('UAST_MAZANDARAN', 'دانشگاه جامع علمی کاربردی مازندران'), ('UAST_MARKAZI', 'دانشگاه جامع علمی کاربردی مرکزی'), ('UAST_HORMOZGAN', 'دانشگاه جامع علمی کاربردی هرمزگان'), ('UAST_HAMEDAN', 'دانشگاه جامع علمی کاربردی همدان'), ('UAST_YAZD', 'دانشگاه جامع علمی کاربردی یزد'), ('SCIENCE_CULTURE', 'دانشگاه علم و فرهنگ'), ('KHATAM', 'دانشگاه خاتم'), ('SOOREH', 'دانشگاه سوره'), ('MOFID', 'دانشگاه مفید'), ('SHOMAL', 'دانشگاه شمال'), ('QURANIC_UNI', 'دانشگاه علوم و معارف قرآن کریم')], max_length=127, null=True), + ), + ] diff --git a/backend/users/migrations/0004_major_university_models.py b/backend/users/migrations/0004_major_university_models.py new file mode 100644 index 0000000..5bd8f5d --- /dev/null +++ b/backend/users/migrations/0004_major_university_models.py @@ -0,0 +1,116 @@ +from django.db import migrations, models +import django.db.models.deletion + +from utils.choices import MajorChoices, UniversityChoices + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0003_alter_user_university"), + ] + + operations = [ + migrations.CreateModel( + name="University", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("is_deleted", models.BooleanField(default=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("code", models.CharField(max_length=64, unique=True)), + ("name", models.CharField(max_length=255)), + ("is_active", models.BooleanField(default=True)), + ], + options={ + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="Major", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ("is_deleted", models.BooleanField(default=False)), + ("deleted_at", models.DateTimeField(blank=True, null=True)), + ("code", models.CharField(max_length=64, unique=True)), + ("name", models.CharField(max_length=255)), + ("is_active", models.BooleanField(default=True)), + ], + options={ + "ordering": ["name"], + }, + ), + migrations.RenameField( + model_name="user", + old_name="major", + new_name="legacy_major", + ), + migrations.RenameField( + model_name="user", + old_name="university", + new_name="legacy_university", + ), + migrations.AlterField( + model_name="user", + name="legacy_major", + field=models.CharField( + blank=True, + choices=MajorChoices.choices, + editable=False, + max_length=16, + null=True, + ), + ), + migrations.AlterField( + model_name="user", + name="legacy_university", + field=models.CharField( + blank=True, + choices=UniversityChoices.choices, + editable=False, + max_length=127, + null=True, + ), + ), + migrations.AddField( + model_name="user", + name="major", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="users", + to="users.major", + ), + ), + migrations.AddField( + model_name="user", + name="university", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="users", + to="users.university", + ), + ), + ] diff --git a/backend/users/migrations/0005_populate_major_university.py b/backend/users/migrations/0005_populate_major_university.py new file mode 100644 index 0000000..6c1d3a3 --- /dev/null +++ b/backend/users/migrations/0005_populate_major_university.py @@ -0,0 +1,60 @@ +from django.db import migrations + +from utils.choices import MajorChoices, UniversityChoices + + +def seed_reference_models(apps, schema_editor): + Major = apps.get_model("users", "Major") + University = apps.get_model("users", "University") + User = apps.get_model("users", "User") + + major_map = {} + for code, label in MajorChoices.choices: + obj, _ = Major.objects.update_or_create( + code=code, + defaults={"name": label}, + ) + major_map[code] = obj + + university_map = {} + for code, label in UniversityChoices.choices: + obj, _ = University.objects.update_or_create( + code=code, + defaults={"name": label}, + ) + university_map[code] = obj + + users = User.objects.all() + for user in users.iterator(): + updates = [] + major_code = getattr(user, "legacy_major", None) + if major_code: + major = major_map.get(major_code) + if major and user.major_id != major.id: + user.major_id = major.id + updates.append("major") + + university_code = getattr(user, "legacy_university", None) + if university_code: + uni = university_map.get(university_code) + if uni and user.university_id != uni.id: + user.university_id = uni.id + updates.append("university") + + if updates: + user.save(update_fields=updates) + + +def noop(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0004_major_university_models"), + ] + + operations = [ + migrations.RunPython(seed_reference_models, noop), + ] diff --git a/backend/users/migrations/0006_remove_legacy_fields.py b/backend/users/migrations/0006_remove_legacy_fields.py new file mode 100644 index 0000000..75de033 --- /dev/null +++ b/backend/users/migrations/0006_remove_legacy_fields.py @@ -0,0 +1,19 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0005_populate_major_university"), + ] + + operations = [ + migrations.RemoveField( + model_name="user", + name="legacy_major", + ), + migrations.RemoveField( + model_name="user", + name="legacy_university", + ), + ] diff --git a/backend/users/migrations/__init__.py b/backend/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/users/models.py b/backend/users/models.py new file mode 100644 index 0000000..6d5ea10 --- /dev/null +++ b/backend/users/models.py @@ -0,0 +1,112 @@ +from django.contrib.auth.models import AbstractUser +from django.utils import timezone +from django.db import models + +import uuid +from datetime import timedelta + +from utils.models import BaseModel + + +class University(BaseModel): + code = models.CharField(max_length=64, unique=True) + name = models.CharField(max_length=255) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["name"] + + def __str__(self): + return self.name + + +class Major(BaseModel): + code = models.CharField(max_length=64, unique=True) + name = models.CharField(max_length=255) + is_active = models.BooleanField(default=True) + + class Meta: + ordering = ["name"] + + def __str__(self): + return self.name + + +class User(AbstractUser, BaseModel): + email = models.EmailField(unique=True) + bio = models.TextField(null=True, blank=True) + profile_picture = models.ImageField(upload_to='profile_pictures/', null=True, blank=True) + + student_id = models.CharField(max_length=20, null=True) + year_of_study = models.IntegerField(null=True, blank=True) + major = models.ForeignKey( + Major, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='users', + ) + university = models.ForeignKey( + University, + null=True, + blank=True, + on_delete=models.SET_NULL, + related_name='users', + ) + is_email_verified = models.BooleanField(default=False) + email_verification_token = models.UUIDField(default=uuid.uuid4, unique=True) + email_verification_sent_at = models.DateTimeField(null=True, blank=True) + + password_reset_token = models.UUIDField(null=True, blank=True, unique=True) + password_reset_token_expires_at = models.DateTimeField(null=True, blank=True) + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['username'] + + class Meta: + db_table = 'users' + verbose_name = 'User' + verbose_name_plural = 'Users' + + def __str__(self): + return f"{self.get_full_name()} ({self.email})" + + def get_full_name(self): + return f"{self.first_name} {self.last_name}".strip() + + def get_major_display(self): + if self.major: + return self.major.name + return None + + def get_university_display(self): + if self.university: + return self.university.name + return None + + def regenerate_verification_token(self): + self.email_verification_token = uuid.uuid4() + self.save(update_fields=['email_verification_token']) + + def set_password_reset_token(self): + """Generates a new password reset token and sets its expiry.""" + self.password_reset_token = uuid.uuid4() + self.password_reset_token_expires_at = timezone.now() + timedelta(hours=1) + self.save(update_fields=['password_reset_token', 'password_reset_token_expires_at']) + + def save(self, *args, **kwargs): + send_verified_success = False + + if self.pk is not None: + prev = type(self).objects.filter(pk=self.pk).values_list('is_email_verified', flat=True).first() + if prev is not None and prev is False and self.is_email_verified is True: + send_verified_success = True + + super().save(*args, **kwargs) + + if send_verified_success: + try: + from users.tasks import send_email_verified_success + send_email_verified_success.delay(self.id) + except Exception: + pass diff --git a/backend/users/resources.py b/backend/users/resources.py new file mode 100644 index 0000000..fef3f3f --- /dev/null +++ b/backend/users/resources.py @@ -0,0 +1,29 @@ +from import_export import resources, fields +from import_export.widgets import BooleanWidget + +from users.models import User + +class UserResource(resources.ModelResource): + is_staff = fields.Field( + column_name='is_staff', + attribute='is_staff', + widget=BooleanWidget() + ) + is_superuser = fields.Field( + column_name='is_superuser', + attribute='is_superuser', + widget=BooleanWidget() + ) + is_email_verified = fields.Field( + column_name='is_email_verified', + attribute='is_email_verified', + widget=BooleanWidget() + ) + + class Meta: + model = User + fields = ('id', 'username', 'email', 'first_name', 'last_name', + 'student_id', 'year_of_study', 'major', + 'is_staff', 'is_superuser', + 'is_email_verified', 'bio') + export_order = fields diff --git a/backend/users/signals.py b/backend/users/signals.py new file mode 100644 index 0000000..537327c --- /dev/null +++ b/backend/users/signals.py @@ -0,0 +1,27 @@ +import uuid + +from django.db.models.signals import post_save +from django.dispatch import receiver +from django.utils import timezone +from django.conf import settings + +from users.models import User +from users.tasks import send_verification_email + +@receiver(post_save, sender=User) +def send_verification_email_on_registration(sender, instance, created, **kwargs): + if created: + if not instance.username: + instance.username = str(uuid.uuid4())[:10] + instance.save(update_fields=['username']) + + if not instance.is_email_verified and instance.email: + # Update the email verification sent timestamp + instance.email_verification_sent_at = timezone.now() + instance.save(update_fields=['email_verification_sent_at']) + + # Generate verification URL (you'll need to adjust this based on your frontend) + verification_url = f"{settings.FRONTEND_ROOT}verify-email/{instance.email_verification_token}" + + # Send verification email asynchronously + send_verification_email.delay(instance.id, verification_url) diff --git a/backend/users/tasks.py b/backend/users/tasks.py new file mode 100644 index 0000000..c3f0aa9 --- /dev/null +++ b/backend/users/tasks.py @@ -0,0 +1,99 @@ +from django.core.mail import send_mail +from django.template.loader import render_to_string +from django.conf import settings +from django.utils.html import strip_tags + +from celery import shared_task +import logging + +from users.models import User + +logger = logging.getLogger(__name__) + +@shared_task(bind=True, max_retries=3) +def send_verification_email(self, user_id, verification_url): + try: + user = User.objects.get(id=user_id) + + subject = 'تایید ایمیل | انجمن علمی مهندسی کامپیوتر' + html_message = render_to_string('emails/verification_email.html', { + 'user': user, + 'verification_url': verification_url, + }) + plain_message = strip_tags(html_message) + + send_mail( + subject=subject, + message=plain_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) + + logger.info(f"Verification email sent to {user.email}") + return f"Verification email sent to {user.email}" + + except Exception as exc: + logger.error(f"Failed to send verification email: {exc}") + raise self.retry(exc=exc, countdown=60) + +@shared_task(bind=True, max_retries=3) +def send_password_reset_email(self, user_id, reset_url): + try: + user = User.objects.get(id=user_id) + + subject = 'بازیابی رمز عبور | انجمن علمی مهندسی کامپیوتر' + html_message = render_to_string('emails/password_reset_email.html', { + 'user': user, + 'reset_url': reset_url, + }) + plain_message = strip_tags(html_message) + + send_mail( + subject=subject, + message=plain_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) + + logger.info(f"Password reset email sent to {user.email}") + return f"Password reset email sent to {user.email}" + + except Exception as exc: + logger.error(f"Failed to send password reset email: {exc}") + raise self.retry(exc=exc, countdown=60) + + +@shared_task(bind=True, max_retries=3) +def send_email_verified_success(self, user_id: int): + """ + ارسال ایمیل «ایمیل شما با موفقیت تأیید شد» پس از تغییر وضعیت تأیید. + """ + try: + user = User.objects.get(pk=user_id) + + subject = "تأیید ایمیل شما با موفقیت انجام شد" + context = { + "user": user, + "home_url": getattr(settings, "FRONTEND_ROOT", "/"), + } + html_message = render_to_string("emails/verification_success.html", context) + plain_message = strip_tags(html_message) + + send_mail( + subject=subject, + message=plain_message, + from_email=settings.DEFAULT_FROM_EMAIL, + recipient_list=[user.email], + html_message=html_message, + fail_silently=False, + ) + logger.info(f"verified success email sent to {user.email}") + return f"verified success email sent to {user.email}" + + except Exception as exc: + logger.error(f"Failed to send verified success email: {exc}") + raise self.retry(exc=exc, countdown=60) diff --git a/backend/utils/admin.py b/backend/utils/admin.py new file mode 100644 index 0000000..36387e0 --- /dev/null +++ b/backend/utils/admin.py @@ -0,0 +1,85 @@ +from django.contrib import admin, messages +from django.utils.translation import gettext_lazy as _ +from django.db import transaction +from django.db.models.deletion import ProtectedError + +from unfold.admin import ModelAdmin + +class SoftDeleteListFilter(admin.SimpleListFilter): + title = _('Soft Delete Status') + parameter_name = 'is_deleted' + + def lookups(self, request, model_admin): + return [ + ('0', _('Active')), + ('1', _('Deleted')), + ] + + def queryset(self, request, queryset): + if self.value() == '0': + return queryset.filter(is_deleted=False) + + if self.value() == '1': + return queryset.model.deleted_objects.all() + + return queryset + + +class BaseModelAdmin(ModelAdmin): + actions = ["hard_delete_selected", "restore_selected"] + + def get_queryset(self, request): + return self.model.all_objects.all() + + @admin.action(description=_('Hard delete selected (permanent)')) + def hard_delete_selected(self, request, queryset): + """ + حذف فیزیکی رکوردهای انتخاب‌شده (دورزدن SoftDelete). + """ + count = queryset.count() + try: + with transaction.atomic(): + queryset.hard_delete() + self.message_user( + request, + _('%(count)d record(s) permanently deleted.') % {'count': count}, + level=messages.SUCCESS + ) + except ProtectedError: + self.message_user( + request, + _('Cannot hard delete because related protected objects exist.'), + level=messages.ERROR + ) + except Exception as e: + self.message_user(request, str(e), level=messages.ERROR) + + @admin.action(description=_('Restore selected (undo soft delete)')) + def restore_selected(self, request, queryset): + """ + بازگردانی رکوردهای soft-deleted. + """ + restored = 0 + for obj in queryset: + if getattr(obj, "is_deleted", False): + obj.restore() + restored += 1 + self.message_user( + request, + _('%(count)d record(s) restored.') % {'count': restored}, + level=messages.SUCCESS + ) + + def get_actions(self, request): + actions = super().get_actions(request) + + if not request.user.is_superuser: + actions.pop("hard_delete_selected", None) + + is_deleted_filter = request.GET.get('is_deleted') + should_show_restore_actions = is_deleted_filter == '1' + if not should_show_restore_actions: + actions.pop('restore_selected', None) + actions.pop('hard_delete_selected', None) + + return actions diff --git a/backend/utils/choices.py b/backend/utils/choices.py new file mode 100644 index 0000000..e4599e2 --- /dev/null +++ b/backend/utils/choices.py @@ -0,0 +1,293 @@ +from enum import Enum +from django.db import models + +class MajorChoices(models.TextChoices): + # مهندسی و کامپیوتر + CE = 'CE', 'مهندسی کامپیوتر' + CS = 'CS', 'علوم کامپیوتر' + SE = 'SE', 'مهندسی نرم‌افزار' + IT = 'IT', 'فناوری اطلاعات' + AI = 'AI', 'هوش مصنوعی و رباتیک' + DATA = 'DATA', 'علم داده' + + EE = 'EE', 'مهندسی برق' + ME = 'ME', 'مهندسی مکانیک' + CIV = 'CIV', 'مهندسی عمران' + CHE = 'CHE', 'مهندسی شیمی' + IE = 'IE', 'مهندسی صنایع' + MSE = 'MSE', 'مهندسی مواد و متالورژی' + BME = 'BME', 'مهندسی پزشکی' + ARCH = 'ARCH', 'معماری' + AERO = 'AERO', 'مهندسی هوافضا' + PET = 'PET', 'مهندسی نفت' + MIN = 'MIN', 'مهندسی معدن' + ENV = 'ENV', 'مهندسی محیط‌زیست' + URP = 'URP', 'برنامه‌ریزی شهری و منطقه‌ای' + + # علوم پایه + MATH = 'MATH', 'ریاضیات' + STAT = 'STAT', 'آمار' + PHYS = 'PHYS', 'فیزیک' + CHEM = 'CHEM', 'شیمی' + BIO = 'BIO', 'زیست‌شناسی' + GEO = 'GEO', 'زمین‌شناسی' + + # پزشکی و پیراپزشکی (در صورت داشتن دانشکده‌های مربوط) + MED = 'MED', 'پزشکی' + DEN = 'DEN', 'دندان‌پزشکی' + PHARM= 'PHARM','داروسازی' + NURS = 'NURS', 'پرستاری' + MID = 'MID', 'مامایی' + LAB = 'LAB', 'علوم آزمایشگاهی' + RAD = 'RAD', 'رادیولوژی' + ANES = 'ANES', 'بیهوشی' + PUBH = 'PUBH', 'بهداشت' + + # کشاورزی و دامپزشکی (اگر دارید) + AGRI = 'AGRI', 'کشاورزی (عمومی)' + HORT = 'HORT', 'باغبانی' + PLP = 'PLP', 'گیاه‌پزشکی' + SOIL = 'SOIL', 'علوم خاک' + VET = 'VET', 'دامپزشکی' + + # مدیریت و اقتصاد + MGT = 'MGT', 'مدیریت' + ACC = 'ACC', 'حسابداری' + FIN = 'FIN', 'مالی' + ECO = 'ECO', 'اقتصاد' + BA = 'BA', 'مدیریت بازرگانی' + + # علوم انسانی و هنر + LAW = 'LAW', 'حقوق' + POL = 'POL', 'علوم سیاسی' + SOC = 'SOC', 'جامعه‌شناسی' + PSY = 'PSY', 'روان‌شناسی' + PHIL = 'PHIL', 'فلسفه' + HIST = 'HIST', 'تاریخ' + GEOG = 'GEOG', 'جغرافیا' + EDU = 'EDU', 'علوم تربیتی' + PEd = 'PEd', 'تربیت بدنی' + LIT_FA = 'LIT_FA', 'زبان و ادبیات فارسی' + LIT_EN = 'LIT_EN', 'زبان و ادبیات انگلیسی' + LIT_AR = 'LIT_AR', 'زبان و ادبیات عربی' + TRAN_EN= 'TRAN_EN','مترجمی زبان انگلیسی' + ART = 'ART', 'هنرهای تجسمی' + GRAPH= 'GRAPH','گرافیک' + MUSIC= 'MUSIC','موسیقی' + THEAT= 'THEAT','نمایش و تئاتر' + + +from django.db import models + +class UniversityChoices(models.TextChoices): + """University codes preserving legacy constant names for backward compatibility.""" + # ========= دولتی (وزارت علوم) ========= + # موارد قبلی شما (بدون تغییر کدها) + GILAN = 'GILAN', 'دانشگاه گیلان' + UT = 'UT', 'دانشگاه تهران' + AUT = 'AUT', 'دانشگاه صنعتی امیرکبیر' + SHARIF = 'SHARIF', 'دانشگاه صنعتی شریف' + SBU = 'SBU', 'دانشگاه شهید بهشتی' + IUST = 'IUST', 'دانشگاه علم و صنعت ایران' + KNTU = 'KNTU', 'دانشگاه صنعتی خواجه‌نصیر' + MODARES = 'MODARES', 'دانشگاه تربیت مدرس' + ALLAMEH = 'ALLAMEH', 'دانشگاه علامه طباطبایی' + KHARAZMI = 'KHARAZMI', 'دانشگاه خوارزمی' + ISFAHAN_UNI = 'ISFAHAN_UNI', 'دانشگاه اصفهان' + IUT = 'IUT', 'دانشگاه صنعتی اصفهان' + SHIRAZ_UNI = 'SHIRAZ_UNI', 'دانشگاه شیراز' + SHIRAZ_TECH = 'SHIRAZ_TECH', 'دانشگاه صنعتی شیراز' + TABRIZ_UNI = 'TABRIZ_UNI', 'دانشگاه تبریز' + FERDOWSI = 'FERDOWSI', 'دانشگاه فردوسی مشهد' + IMAMREZA = 'IMAMREZA', 'دانشگاه بین المللی امام رضا مشهد' + RAZI = 'RAZI', 'دانشگاه رازی' + SHAHRKORD = 'SHAHRKORD', 'دانشگاه شهرکرد' + BUALI = 'BUALI', 'دانشگاه بوعلی‌سینا' + KURDISTAN = 'KURDISTAN', 'دانشگاه کردستان' + YAZD_UNI = 'YAZD_UNI', 'دانشگاه یزد' + KERMAN_UNI = 'KERMAN_UNI', 'دانشگاه شهید باهنر کرمان' + MAZANDARAN = 'MAZANDARAN', 'دانشگاه مازندران' + GOLESTAN = 'GOLESTAN', 'دانشگاه گلستان' + URMIA = 'URMIA', 'دانشگاه ارومیه' + ZANJAN = 'ZANJAN', 'دانشگاه زنجان' + ARDABIL = 'ARDABIL', 'دانشگاه محقق اردبیلی' + ARak_UNI = 'ARAK_UNI', 'دانشگاه اراک' + SEMNAN = 'SEMNAN', 'دانشگاه سمنان' + SHAHROOD = 'SHAHROOD', 'دانشگاه صنعتی شاهرود' + QOM_UNI = 'QOM_UNI', 'دانشگاه قم' + QOM_TECH = 'QOM_TECH', 'دانشگاه صنعتی قم' + IKIU = 'IKIU', 'دانشگاه بین‌المللی امام خمینی قزوین' + MAL_ASHTAR = 'MAL_ASHTAR', 'دانشگاه صنعتی مالک‌اشتر' + SAHAND = 'SAHAND', 'دانشگاه صنعتی سهند' + BABOL_NOSH = 'BABOL_NOSH', 'دانشگاه صنعتی نوشیروانی بابل' + BIRGAND = 'BIRGAND', 'دانشگاه بیرجند' + + # ======= افزودنی‌های دولتی (نمونه‌های شاخص و پرتکرار) ======= + ALZAHRA = 'ALZAHRA', 'دانشگاه الزهرا' + TAFRESH = 'TAFRESH', 'دانشگاه تفرش' + JAHROM = 'JAHROM', 'دانشگاه جهرم' + HAKIM_SABZ = 'HAKIM_SABZ', 'دانشگاه حکیم سبزواری' + PERSIAN_GULF = 'PERSIAN_GULF', 'دانشگاه خلیج فارس' + DAMGHAN = 'DAMGHAN', 'دانشگاه دامغان' + ILAM = 'ILAM', 'دانشگاه ایلام' + BOJNORD = 'BOJNORD', 'دانشگاه بجنورد' + KASHAN = 'KASHAN', 'دانشگاه کاشان' + LORESTAN = 'LORESTAN', 'دانشگاه لرستان' + MARAGHEH = 'MARAGHEH', 'دانشگاه مراغه' + MALAYER = 'MALAYER', 'دانشگاه ملایر' + NEYSHABUR = 'NEYSHABUR', 'دانشگاه نیشابور' + HORMOZGAN = 'HORMOZGAN', 'دانشگاه هرمزگان' + HONAR = 'HONAR', 'دانشگاه هنر' + + # ========= علوم پزشکی ========= + TUMS = 'TUMS', 'دانشگاه علوم پزشکی تهران' + SBMU_MED = 'SBMU_MED', 'دانشگاه علوم پزشکی شهید بهشتی' + IUMS_MED = 'IUMS_MED', 'دانشگاه علوم پزشکی ایران' + MUMS_MED = 'MUMS_MED', 'دانشگاه علوم پزشکی مشهد' + SUMS_MED = 'SUMS_MED', 'دانشگاه علوم پزشکی شیراز' + TBZ_MED = 'TBZ_MED', 'دانشگاه علوم پزشکی تبریز' + ISF_MED = 'ISF_MED', 'دانشگاه علوم پزشکی اصفهان' + AJUMS_MED = 'AJUMS_MED', 'دانشگاه علوم پزشکی اهواز' + AJA_MED = 'AJA_MED', 'دانشگاه علوم پزشکی ارتش' + KUMS_MED = 'KUMS_MED', 'دانشگاه علوم پزشکی کرمانشاه' + KER_MED = 'KER_MED', 'دانشگاه علوم پزشکی کرمان' + MED_QOM = 'MED_QOM', 'دانشگاه علوم پزشکی قم' + MED_QAZVIN = 'MED_QAZVIN', 'دانشگاه علوم پزشکی قزوین' + MED_ALBORZ = 'MED_ALBORZ', 'دانشگاه علوم پزشکی البرز' + MED_ARAK = 'MED_ARAK', 'دانشگاه علوم پزشکی اراک' + MED_ZANJAN = 'MED_ZANJAN', 'دانشگاه علوم پزشکی زنجان' + MED_MAZANDARAN= 'MED_MAZANDARAN','دانشگاه علوم پزشکی مازندران' + MED_BABOL = 'MED_BABOL', 'دانشگاه علوم پزشکی بابل' + MED_GOLESTAN = 'MED_GOLESTAN', 'دانشگاه علوم پزشکی گلستان' + MED_GILAN = 'MED_GILAN', 'دانشگاه علوم پزشکی گیلان' + MED_HORMOZGAN = 'MED_HORMOZGAN', 'دانشگاه علوم پزشکی هرمزگان' + MED_BUSHEHR = 'MED_BUSHEHR', 'دانشگاه علوم پزشکی بوشهر' + MED_BIRJAND = 'MED_BIRJAND', 'دانشگاه علوم پزشکی بیرجند' + MED_BOJNORD = 'MED_BOJNORD', 'دانشگاه علوم پزشکی خراسان شمالی (بجنورد)' + MED_SABZEVAR = 'MED_SABZEVAR', 'دانشگاه علوم پزشکی سبزوار' + MED_NEYSHABUR = 'MED_NEYSHABUR', 'دانشگاه علوم پزشکی نیشابور' + MED_GONABAD = 'MED_GONABAD', 'دانشگاه علوم پزشکی گناباد' + MED_SHAHROUD = 'MED_SHAHROUD', 'دانشگاه علوم پزشکی شاهرود' + MED_SEMNAN = 'MED_SEMNAN', 'دانشگاه علوم پزشکی سمنان' + MED_YAZD = 'MED_YAZD', 'دانشگاه علوم پزشکی یزد' + MED_URMIA = 'MED_URMIA', 'دانشگاه علوم پزشکی ارومیه' + MED_ARDABIL = 'MED_ARDABIL', 'دانشگاه علوم پزشکی اردبیل' + MED_HAMEDAN = 'MED_HAMEDAN', 'دانشگاه علوم پزشکی همدان' + MED_LARESTAN = 'MED_LARESTAN', 'دانشکده علوم پزشکی لارستان' + MED_FASA = 'MED_FASA', 'دانشگاه علوم پزشکی فسا' + MED_JAHROM = 'MED_JAHROM', 'دانشگاه علوم پزشکی جهرم' + MED_KASHAN = 'MED_KASHAN', 'دانشگاه علوم پزشکی کاشان' + MED_ILAM = 'MED_ILAM', 'دانشگاه علوم پزشکی ایلام' + MED_LORESTAN = 'MED_LORESTAN', 'دانشگاه علوم پزشکی لرستان' + MED_KHUZESTAN = 'MED_KHUZESTAN', 'دانشگاه علوم پزشکی دزفول/شوشتر (استان خوزستان)' + + # ========= آزاد اسلامی (واحدهای شاخص و پرتردد) ========= + IAU_TEH_CENTRAL = 'IAU_TEH_CENTRAL', 'دانشگاه آزاد اسلامی واحد تهران مرکزی' + IAU_TEH_NORTH = 'IAU_TEH_NORTH', 'دانشگاه آزاد اسلامی واحد تهران شمال' + IAU_TEH_SOUTH = 'IAU_TEH_SOUTH', 'دانشگاه آزاد اسلامی واحد تهران جنوب' + IAU_TEH_WEST = 'IAU_TEH_WEST', 'دانشگاه آزاد اسلامی واحد تهران غرب' + IAU_TEH_EAST = 'IAU_TEH_EAST', 'دانشگاه آزاد اسلامی واحد تهران شرق' + IAU_SRT_TEHRAN = 'IAU_SRT_TEHRAN', 'دانشگاه آزاد اسلامی واحد علوم و تحقیقات تهران' + IAU_QAZVIN = 'IAU_QAZVIN', 'دانشگاه آزاد اسلامی قزوین' + IAU_NAJAFABAD = 'IAU_NAJAFABAD', 'دانشگاه آزاد اسلامی نجف‌آباد' + IAU_MASHHAD = 'IAU_MASHHAD', 'دانشگاه آزاد اسلامی مشهد' + IAU_TABRIZ = 'IAU_TABRIZ', 'دانشگاه آزاد اسلامی تبریز' + IAU_SHIRAZ = 'IAU_SHIRAZ', 'دانشگاه آزاد اسلامی شیراز' + IAU_ISFAHAN = 'IAU_ISFAHAN', 'دانشگاه آزاد اسلامی اصفهان (خوراسگان)' + IAU_KARAJ = 'IAU_KARAJ', 'دانشگاه آزاد اسلامی کرج' + IAU_QOM = 'IAU_QOM', 'دانشگاه آزاد اسلامی قم' + IAU_RASHT = 'IAU_RASHT', 'دانشگاه آزاد اسلامی رشت' + IAU_LAHIJAN = 'IAU_LAHIJAN', 'دانشگاه آزاد اسلامی لاهیجان' + IAU_SARI = 'IAU_SARI', 'دانشگاه آزاد اسلامی ساری' + IAU_YAZD = 'IAU_YAZD', 'دانشگاه آزاد اسلامی یزد' + IAU_KERMAN = 'IAU_KERMAN', 'دانشگاه آزاد اسلامی کرمان' + IAU_BANDARABBAS = 'IAU_BANDARABBAS', 'دانشگاه آزاد اسلامی بندرعباس' + IAU_BUSHEHR = 'IAU_BUSHEHR', 'دانشگاه آزاد اسلامی بوشهر' + IAU_AHVAZ = 'IAU_AHVAZ', 'دانشگاه آزاد اسلامی اهواز' + IAU_KHORRAMABAD = 'IAU_KHORRAMABAD', 'دانشگاه آزاد اسلامی خرم‌آباد' + IAU_SANANDAJ = 'IAU_SANANDAJ', 'دانشگاه آزاد اسلامی سنندج' + IAU_HAMEDAN = 'IAU_HAMEDAN', 'دانشگاه آزاد اسلامی همدان' + IAU_ARAK = 'IAU_ARAK', 'دانشگاه آزاد اسلامی اراک' + IAU_URMIA = 'IAU_URMIA', 'دانشگاه آزاد اسلامی ارومیه' + IAU_ZANJAN = 'IAU_ZANJAN', 'دانشگاه آزاد اسلامی زنجان' + IAU_BIRJAND = 'IAU_BIRJAND', 'دانشگاه آزاد اسلامی بیرجند' + IAU_BOJNORD = 'IAU_BOJNORD', 'دانشگاه آزاد اسلامی بجنورد' + IAU_SEMNAN = 'IAU_SEMNAN', 'دانشگاه آزاد اسلامی سمنان' + IAU_GORGAN = 'IAU_GORGAN', 'دانشگاه آزاد اسلامی گرگان' + IAU_MARVDASHT = 'IAU_MARVDASHT', 'دانشگاه آزاد اسلامی مرودشت' + IAU_KISH_INTL = 'IAU_KISH_INTL', 'دانشگاه آزاد اسلامی بین‌الملل کیش' + IAU_QESHM_INTL = 'IAU_QESHM_INTL', 'دانشگاه آزاد اسلامی قشم (بین‌الملل)' + + # ========= پیام نور (به تفکیک استان) ========= + PNU_EAST_AZERBAIJAN = 'PNU_EAST_AZERBAIJAN', 'دانشگاه پیام نور آذربایجان شرقی' + PNU_WEST_AZERBAIJAN = 'PNU_WEST_AZERBAIJAN', 'دانشگاه پیام نور آذربایجان غربی' + PNU_ARDABIL = 'PNU_ARDABIL', 'دانشگاه پیام نور اردبیل' + PNU_ISFAHAN = 'PNU_ISFAHAN', 'دانشگاه پیام نور اصفهان' + PNU_ALBORZ = 'PNU_ALBORZ', 'دانشگاه پیام نور البرز' + PNU_ILAM = 'PNU_ILAM', 'دانشگاه پیام نور ایلام' + PNU_BUSHEHR = 'PNU_BUSHEHR', 'دانشگاه پیام نور بوشهر' + PNU_TEHRAN = 'PNU_TEHRAN', 'دانشگاه پیام نور تهران' + PNU_CH_BAKHTIARI = 'PNU_CH_BAKHTIARI', 'دانشگاه پیام نور چهارمحال و بختیاری' + PNU_SOUTH_KHORASAN = 'PNU_SOUTH_KHORASAN', 'دانشگاه پیام نور خراسان جنوبی' + PNU_RAZAVI_KHORASAN = 'PNU_RAZAVI_KHORASAN', 'دانشگاه پیام نور خراسان رضوی' + PNU_NORTH_KHORASAN = 'PNU_NORTH_KHORASAN', 'دانشگاه پیام نور خراسان شمالی' + PNU_KHUZESTAN = 'PNU_KHUZESTAN', 'دانشگاه پیام نور خوزستان' + PNU_ZANJAN = 'PNU_ZANJAN', 'دانشگاه پیام نور زنجان' + PNU_SEMNAN = 'PNU_SEMNAN', 'دانشگاه پیام نور سمنان' + PNU_SISTAN_BALUCH = 'PNU_SISTAN_BALUCH', 'دانشگاه پیام نور سیستان و بلوچستان' + PNU_FARS = 'PNU_FARS', 'دانشگاه پیام نور فارس' + PNU_QAZVIN = 'PNU_QAZVIN', 'دانشگاه پیام نور قزوین' + PNU_QOM = 'PNU_QOM', 'دانشگاه پیام نور قم' + PNU_KURDISTAN = 'PNU_KURDISTAN', 'دانشگاه پیام نور کردستان' + PNU_KERMAN = 'PNU_KERMAN', 'دانشگاه پیام نور کرمان' + PNU_KERMANSHAH = 'PNU_KERMANSHAH', 'دانشگاه پیام نور کرمانشاه' + PNU_KOHGILUYEH = 'PNU_KOHGILUYEH', 'دانشگاه پیام نور کهگیلویه و بویراحمد' + PNU_GOLESTAN = 'PNU_GOLESTAN', 'دانشگاه پیام نور گلستان' + PNU_GILAN = 'PNU_GILAN', 'دانشگاه پیام نور گیلان' + PNU_LORESTAN = 'PNU_LORESTAN', 'دانشگاه پیام نور لرستان' + PNU_MAZANDARAN = 'PNU_MAZANDARAN', 'دانشگاه پیام نور مازندران' + PNU_MARKAZI = 'PNU_MARKAZI', 'دانشگاه پیام نور مرکزی' + PNU_HORMOZGAN = 'PNU_HORMOZGAN', 'دانشگاه پیام نور هرمزگان' + PNU_HAMEDAN = 'PNU_HAMEDAN', 'دانشگاه پیام نور همدان' + PNU_YAZD = 'PNU_YAZD', 'دانشگاه پیام نور یزد' + + # ========= جامع علمی‌ـ‌کاربردی (به تفکیک استان) ========= + UAST_EAST_AZERBAIJAN = 'UAST_EAST_AZERBAIJAN', 'دانشگاه جامع علمی کاربردی آذربایجان شرقی' + UAST_WEST_AZERBAIJAN = 'UAST_WEST_AZERBAIJAN', 'دانشگاه جامع علمی کاربردی آذربایجان غربی' + UAST_ARDABIL = 'UAST_ARDABIL', 'دانشگاه جامع علمی کاربردی اردبیل' + UAST_ISFAHAN = 'UAST_ISFAHAN', 'دانشگاه جامع علمی کاربردی اصفهان' + UAST_ALBORZ = 'UAST_ALBORZ', 'دانشگاه جامع علمی کاربردی البرز' + UAST_ILAM = 'UAST_ILAM', 'دانشگاه جامع علمی کاربردی ایلام' + UAST_BUSHEHR = 'UAST_BUSHEHR', 'دانشگاه جامع علمی کاربردی بوشهر' + UAST_TEHRAN = 'UAST_TEHRAN', 'دانشگاه جامع علمی کاربردی تهران' + UAST_CH_BAKHTIARI = 'UAST_CH_BAKHTIARI', 'دانشگاه جامع علمی کاربردی چهارمحال و بختیاری' + UAST_SOUTH_KHORASAN = 'UAST_SOUTH_KHORASAN', 'دانشگاه جامع علمی کاربردی خراسان جنوبی' + UAST_RAZAVI_KHORASAN = 'UAST_RAZAVI_KHORASAN', 'دانشگاه جامع علمی کاربردی خراسان رضوی' + UAST_NORTH_KHORASAN = 'UAST_NORTH_KHORASAN', 'دانشگاه جامع علمی کاربردی خراسان شمالی' + UAST_KHUZESTAN = 'UAST_KHUZESTAN', 'دانشگاه جامع علمی کاربردی خوزستان' + UAST_ZANJAN = 'UAST_ZANJAN', 'دانشگاه جامع علمی کاربردی زنجان' + UAST_SEMNAN = 'UAST_SEMNAN', 'دانشگاه جامع علمی کاربردی سمنان' + UAST_SISTAN_BALUCH = 'UAST_SISTAN_BALUCH', 'دانشگاه جامع علمی کاربردی سیستان و بلوچستان' + UAST_FARS = 'UAST_FARS', 'دانشگاه جامع علمی کاربردی فارس' + UAST_QAZVIN = 'UAST_QAZVIN', 'دانشگاه جامع علمی کاربردی قزوین' + UAST_QOM = 'UAST_QOM', 'دانشگاه جامع علمی کاربردی قم' + UAST_KURDISTAN = 'UAST_KURDISTAN', 'دانشگاه جامع علمی کاربردی کردستان' + UAST_KERMAN = 'UAST_KERMAN', 'دانشگاه جامع علمی کاربردی کرمان' + UAST_KERMANSHAH = 'UAST_KERMANSHAH', 'دانشگاه جامع علمی کاربردی کرمانشاه' + UAST_KOHGILUYEH = 'UAST_KOHGILUYEH', 'دانشگاه جامع علمی کاربردی کهگیلویه و بویراحمد' + UAST_GOLESTAN = 'UAST_GOLESTAN', 'دانشگاه جامع علمی کاربردی گلستان' + UAST_GILAN = 'UAST_GILAN', 'دانشگاه جامع علمی کاربردی گیلان' + UAST_LORESTAN = 'UAST_LORESTAN', 'دانشگاه جامع علمی کاربردی لرستان' + UAST_MAZANDARAN = 'UAST_MAZANDARAN', 'دانشگاه جامع علمی کاربردی مازندران' + UAST_MARKAZI = 'UAST_MARKAZI', 'دانشگاه جامع علمی کاربردی مرکزی' + UAST_HORMOZGAN = 'UAST_HORMOZGAN', 'دانشگاه جامع علمی کاربردی هرمزگان' + UAST_HAMEDAN = 'UAST_HAMEDAN', 'دانشگاه جامع علمی کاربردی همدان' + UAST_YAZD = 'UAST_YAZD', 'دانشگاه جامع علمی کاربردی یزد' + + # ========= غیرانتفاعی / مؤسسات شاخص (نمونه) ========= + SCIENCE_CULTURE = 'SCIENCE_CULTURE', 'دانشگاه علم و فرهنگ' + KHATAM = 'KHATAM', 'دانشگاه خاتم' + SOOREH = 'SOOREH', 'دانشگاه سوره' + MOFID = 'MOFID', 'دانشگاه مفید' + SHOMAL = 'SHOMAL', 'دانشگاه شمال' + QURANIC_UNI = 'QURANIC_UNI', 'دانشگاه علوم و معارف قرآن کریم' diff --git a/backend/utils/models.py b/backend/utils/models.py new file mode 100644 index 0000000..aee1982 --- /dev/null +++ b/backend/utils/models.py @@ -0,0 +1,57 @@ +from django.db import models +from django.utils import timezone + +class SoftDeleteQuerySet(models.QuerySet): + def delete(self): + return super().update(is_deleted=True, deleted_at=timezone.now()) + + def hard_delete(self): + return super().delete() + + def alive(self): + return self.filter(is_deleted=False) + + def dead(self): + return self.filter(is_deleted=True) + +class SoftDeleteManager(models.Manager): + def __init__(self, *args, **kwargs): + self.alive_only = kwargs.pop('alive_only', None) + super().__init__(*args, **kwargs) + + def get_queryset(self): + if self.alive_only is True: + return SoftDeleteQuerySet(self.model).filter(is_deleted=False) + if self.alive_only is False: + return SoftDeleteQuerySet(self.model).filter(is_deleted=True) + if self.alive_only is None: + return SoftDeleteQuerySet(self.model) + + def hard_delete(self): + return self.get_queryset().hard_delete() + +class BaseModel(models.Model): + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + is_deleted = models.BooleanField(default=False) + deleted_at = models.DateTimeField(null=True, blank=True) + + objects = SoftDeleteManager(alive_only=True) + all_objects = SoftDeleteManager(alive_only=None) + deleted_objects = SoftDeleteManager(alive_only=False) + + class Meta: + abstract = True + + def delete(self, using=None, keep_parents=False): + self.is_deleted = True + self.deleted_at = timezone.now() + self.save(using=using) + + def hard_delete(self, using=None, keep_parents=False): + super().delete(using=using, keep_parents=keep_parents) + + def restore(self): + self.is_deleted = False + self.deleted_at = None + self.save() diff --git a/backend/utils/templatetags/jalali.py b/backend/utils/templatetags/jalali.py new file mode 100644 index 0000000..380f791 --- /dev/null +++ b/backend/utils/templatetags/jalali.py @@ -0,0 +1,23 @@ +from django import template +from django.utils import timezone +import jdatetime +import zoneinfo + +register = template.Library() +TEHRAN_TZ = zoneinfo.ZoneInfo("Asia/Tehran") +PERSIAN_MAP = str.maketrans("0123456789", "۰۱۲۳۴۵۶۷۸۹") + +@register.filter +def jdate(value, fmt="%Y/%m/%d %H:%M"): + """Convert aware/naive datetime to Tehran TZ and format as Jalali.""" + if not value: + return "" + # به زمان تهران + dt = timezone.localtime(value, TEHRAN_TZ) if timezone.is_aware(value) else value.replace(tzinfo=TEHRAN_TZ) + jdt = jdatetime.datetime.fromgregorian(datetime=dt) + return jdt.strftime(fmt) + +@register.filter +def fa_digits(value): + """Convert ASCII digits to Persian digits.""" + return str(value).translate(PERSIAN_MAP) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..2cd22f1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,252 @@ +name: east-guilan + +services: + traefik: + image: traefik:v2.11 + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --entrypoints.web.http.redirections.entryPoint.to=websecure + - --entrypoints.web.http.redirections.entryPoint.scheme=https + - --certificatesresolvers.le.acme.email=${LETSENCRYPT_EMAIL} + - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json + - --certificatesresolvers.le.acme.httpchallenge=true + - --certificatesresolvers.le.acme.httpchallenge.entrypoint=web + + # 🔹 Enable Prometheus metrics + - --metrics.prometheus=true + - --metrics.prometheus.addEntryPointsLabels=true + - --metrics.prometheus.addRoutersLabels=true + - --metrics.prometheus.addServicesLabels=true + + # 🔹 Expose metrics on a separate entrypoint (port 8082) + - --entrypoints.metrics.address=:8082 + - --metrics.prometheus.entryPoint=metrics + + # (optional) dashboard if you want it later: + - --api.dashboard=true + + # Route /metrics on api.east-guilan-ce.ir to web:8000 + - traefik.http.routers.metrics.rule=Host(`api.east-guilan-ce.ir`) && Path(`/metrics`) + - traefik.http.routers.metrics.entrypoints=websecure + - traefik.http.routers.metrics.tls.certresolver=le + - traefik.http.services.metrics.loadbalancer.server.port=8000 + + ports: + - "80:80" + - "443:443" + volumes: + - traefik_letsencrypt:/letsencrypt + - /var/run/docker.sock:/var/run/docker.sock:ro + + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: ${DB_NAME} + POSTGRES_USER: ${DB_USER} + POSTGRES_PASSWORD: ${DB_PASSWORD} + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME} -h 127.0.0.1"] + interval: 10s + timeout: 5s + retries: 10 + volumes: + - postgres_data:/var/lib/postgresql/data + + redis: + image: redis:7-alpine + command: ["redis-server", "--appendonly", "yes", "--requirepass", "${REDIS_PASSWORD}"] + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "PING"] + interval: 10s + timeout: 5s + retries: 10 + volumes: + - redis_data:/data + + web: + build: + context: ./backend + dockerfile: Dockerfile + env_file: .env + environment: + DJANGO_SETTINGS_MODULE: ${DJANGO_SETTINGS_MODULE} + REDIS_URL: ${REDIS_URL} + PROMETHEUS_MULTIPROC_DIR: /tmp/prometheus + tmpfs: + - /tmp/prometheus + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - django_static:/app/staticfiles + - django_media:/app/media + command: > + gunicorn config.wsgi:application + --bind 0.0.0.0:8000 + --workers=${GUNICORN_WORKERS:-3} + --threads=${GUNICORN_THREADS:-2} + --timeout=${GUNICORN_TIMEOUT:-120} + labels: + - traefik.enable=true + + - traefik.http.routers.api.rule=Host(`api.east-guilan-ce.ir`) && PathPrefix(`/api`) + - traefik.http.routers.api.entrypoints=websecure + - traefik.http.routers.api.tls.certresolver=le + - traefik.http.routers.api.priority=10 + - traefik.http.services.api.loadbalancer.server.port=8000 + + - traefik.http.routers.admin.rule=Host(`api.east-guilan-ce.ir`) && PathPrefix(`/admin`) + - traefik.http.routers.admin.entrypoints=websecure + - traefik.http.routers.admin.tls.certresolver=le + - traefik.http.routers.admin.priority=10 + + worker: + build: + context: ./backend + dockerfile: Dockerfile + env_file: .env + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + command: celery -A config.services.celery worker --loglevel=INFO + volumes: + - django_media:/app/media + + beat: + build: + context: ./backend + dockerfile: Dockerfile + env_file: .env + depends_on: + - worker + command: celery -A config.services.celery beat --loglevel=INFO + volumes: + - django_media:/app/media + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + args: + NEXT_PUBLIC_API_BASE: https://api.east-guilan-ce.ir + labels: + - traefik.enable=true + - traefik.http.routers.frontend.rule=Host(`${NEXT_HOST}`) + - traefik.http.routers.frontend.entrypoints=websecure + - traefik.http.routers.frontend.tls.certresolver=le + - traefik.http.services.frontend.loadbalancer.server.port=80 + + static: + image: nginx:1.27-alpine + volumes: + - ./nginx-static.conf:/etc/nginx/conf.d/default.conf:ro + - django_static:/var/www/static:ro + - django_media:/var/www/media:ro + labels: + - traefik.enable=true + # higher priority so /static & /media never reach backend + - traefik.http.routers.static.rule=Host(`api.east-guilan-ce.ir`) && PathPrefix(`/static`) + - traefik.http.routers.static.entrypoints=websecure + - traefik.http.routers.static.tls.certresolver=le + - traefik.http.routers.static.priority=20 + - traefik.http.services.static.loadbalancer.server.port=80 + + - traefik.http.routers.media.rule=Host(`api.east-guilan-ce.ir`) && PathPrefix(`/media`) + - traefik.http.routers.media.entrypoints=websecure + - traefik.http.routers.media.tls.certresolver=le + - traefik.http.routers.media.priority=20 + + uptime: + image: louislam/uptime-kuma:1 + restart: unless-stopped + volumes: ["./data/uptime:/app/data"] + labels: + - traefik.enable=true + - traefik.http.routers.kuma.rule=Host(`uptime.east-guilan-ce.ir`) + - traefik.http.routers.kuma.entrypoints=websecure + - traefik.http.routers.kuma.tls.certresolver=le + - traefik.http.services.kuma.loadbalancer.server.port=3001 + + node_exporter: + image: prom/node-exporter:v1.9.1 + restart: unless-stopped + ports: ["9100:9100"] + volumes: + - /:/host:ro,rslave + - /proc:/host/proc:ro + - /sys:/host/sys:ro + - /run/udev:/host/run/udev:ro + command: + - --path.rootfs=/host + - --path.procfs=/host/proc + - --path.sysfs=/host/sys + - --path.udev.data=/host/run/udev/data + + postgres_exporter: + image: quay.io/prometheuscommunity/postgres-exporter:v0.15.0 + restart: unless-stopped + environment: + - DATA_SOURCE_NAME=postgresql://${DB_USER}:${DB_PASSWORD}@db:5432/${DB_NAME}?sslmode=disable + depends_on: + db: + condition: service_healthy + + redis_exporter: + image: oliver006/redis_exporter:v1.62.0 + restart: unless-stopped + command: + - '--redis.addr=redis://redis:6379' + - '--redis.password=${REDIS_PASSWORD}' + + grafana: + image: grafana/grafana + restart: unless-stopped + labels: + - traefik.enable=true + - traefik.http.routers.grafana.rule=Host(`grafana.east-guilan-ce.ir`) + - traefik.http.routers.grafana.entrypoints=websecure + - traefik.http.routers.grafana.tls.certresolver=le + - traefik.http.services.grafana.loadbalancer.server.port=3000 + volumes: + - grafana_data:/var/lib/grafana + - ./grafana-datasources.yml:/etc/grafana/provisioning/datasources/datasource.yml:ro + environment: + - GF_SECURITY_ADMIN_USER=admin + - GF_SECURITY_ADMIN_PASSWORD=changeMeNow + - GF_USERS_ALLOW_SIGN_UP=false + + alertmanager: + image: prom/alertmanager + restart: unless-stopped + volumes: + - ./alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro + command: --config.file=/etc/alertmanager/alertmanager.yml + + prometheus: + image: prom/prometheus + volumes: + - prometheus_data:/prometheus + - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro + restart: unless-stopped + labels: + - traefik.enable=true + - traefik.http.routers.prom.rule=Host(`prometheus.east-guilan-ce.ir`) + - traefik.http.routers.prom.entrypoints=websecure + - traefik.http.routers.prom.tls.certresolver=le + - traefik.http.services.prom.loadbalancer.server.port=9090 + +volumes: + traefik_letsencrypt: + postgres_data: + redis_data: + django_media: + django_static: + prometheus_data: + grafana_data: diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..60ce343 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,32 @@ +FROM node:20-alpine AS builder + +WORKDIR /app + +# Accept build argument for API base URL +ARG NEXT_PUBLIC_API_BASE +ENV NEXT_PUBLIC_API_BASE=${NEXT_PUBLIC_API_BASE} + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm install + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production image +FROM nginx:alpine + +# Copy built files to nginx +COPY --from=builder /app/dist /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +EXPOSE 80 + +CMD ["nginx", "-g", "daemon off;"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..a96292e --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,36 @@ +# Frontend + +## Stack +- Vite + React 18 with TypeScript. +- `@tanstack/react-query` for data fetching and caching. +- shadcn/ui primitives (button, card, tabs, dialog, etc.) with Tailwind CSS. +- Sonner & Toast UI for notifications, Markdown rendering, RTL layout, and Persian-digit helpers. + +## Development + +### Install dependencies +```bash +npm install +``` + +### Run dev server +```bash +npm run dev -- --host +``` + +### Production build +```bash +npm run build +``` + +## Features +- **Public site**: homepage, events list/detail, blog list, auth flows, profile, payments. +- **Admin dashboard**: staff-only portal with vertical tabs, user filtering, event filtering, popup detail with registrations/payments, and inline event editing/deletion. +- **Utils**: Persian digit formatting, price conversion (Rial → Toman), shared API client with JWT token refresh handling, and helper components (scroll area, table, dialog). + +## Testing & linting +```bash +npm run lint +``` + +JavaScript/TypeScript linting is configured through ESLint + `typescript-eslint`. Run lint before commits to keep code healthy. diff --git a/frontend/bun.lockb b/frontend/bun.lockb new file mode 100644 index 0000000000000000000000000000000000000000..d3914e8476ac7bcf7821f221922c3af9816eb903 GIT binary patch literal 197327 zcmeFa2{@Hq_Xm7PrZN_hlrckylp-=jnUc9A$~?~$MM+2#rCBNs8jzByIVF+?DWs&7 z5}IckzqLB|e!Ac1dfqyR_qx9S_g&Ar-rMi&;kVY>Yp=cUec$Iiz4EeSW1=F*dIp4! z^$A@d;~5jm50`pym{*W*K(LRxZ&+lAN3{9^O@2-WgP|%|kh*+hZ)(9~@dKQC>5iHj zC310P@m62NOVuZY9l2`AIRJVw7n44^O6W4Q`Q za)7yjSg!%~BLF{hGZ>P9?_hZ7M+TIK09sP*Lja|q{0QnL0IvWJ11tqZyL+hquLKy3 zK~Rp43J&lDe?tWs4ESH-1E?1UtOXPUTqDHvzZlA>Cjt1PfRTX6w}x?}{R^N2%TY02 zkseV#jFZp_{R^oaI}VuUP$4j|j0^H00}KA{1=ag1eDc?}?#NIV2Q5kSA-Fm#LI z2ToxBkbuyDaF3{{A}C`#?ErB+=2W>63gBv@DmaPbFr?}$p^W1x14R8tASlRB0^|pr z1HnX`31ytGXHdp*wgIBuArv+YXY%DCxY++sdj77T=yQchME{6h(vRTZ{UN-uFdiI7 zyfo9V2nrYa2YC5IoC2bJVtoJyjAX{40T9OEO1gsQ{}KpgimK=j|=BPwiRD1*Tz&$Kg?(t8NX*zOe- zKdqn#E)Uy!uV7^R&^@-z)3JVSOi9~;}Pju8|EoQy9wt92`41z)iphsk+ zN8D`~6OIqcQT`s`K7g1?*dFEO5$y9so5`1leyAVny~2XSB5R?Z;`!h{FcjKxU2;?X zK7m~<-=DzbbyDS9fS7m1I?VPe%D*zIU7aeAqHy3O=J-URjCsxth;e-n^{^Erb^~I5 z{oIe$1EPX;nRx*@7#$bx!*GW(`kkZ4oR(Fpc)WIjOTcL zX55tl(Vhgq@B5TblxIvp zus1`^kr_uxK;#Vs#CW`OVAekX#PU@@Y(GiW@2Be50iu8LRM`g*$3K&**9RN|Wo1Cr zBMvAE$Vru-+cWuh0I}aiK%AGOfY?8aYEPxg(SV{*9~l-L923r9T%F5|Um+meDiSjQ zG47F6y))nlC{F^!b_qZk!1t~Uh5}$CAoec@#P*GV@_>nIA!xQ+w-Lc=1# z7vF$TZ*>UdNm#8|e}uwtJc`2F2Mh=ah-MrKX12EiAMG23F!jSNc>(%W24&>?275$D z`*<^sP=5JD#Y6`LM=^Y&B4T_Z;}}n26gV!QsPIT1urC4^&bwzAbKD+YVUak{Rg~W= zVU95#*3b|0!Q00(28O~2i9)NeulaifgUgJ92ar z#4zzXAnxA*U>5zwo2>_oTY=KAwvd_Ea1{BOPh{MrJRj{p1Hm`A#KpFG(b`sNm zEg<^O?!HyLoEcZLf9-@a?l1pW_OY|n_>@;O<0k-!`%DovUXn-KARbts2#E27^!u5A z!s*QQ&jpD2_-ri`r81b~hQq5zu!pygAe1qFpP?Vjbz&Rz!+67X_;Vf^>zQ%sUdNo5 zU75`FkwRfGAlh{X#5~mn#CemU>UjY%AKri--2d+YVz~+s?VSR|b$(VKlc%8 z90Rv7_s^s%d2p3bQxkW%ef$2Pjh;uGg?o?03v^h> zb9{*8Sy>i%S`Cw3j0|DbgUvAg7@t64zE5HE7}sX*Yrotgyi9+m$?_$*nOMs z`jC-wC3WM3BK|?za-lkl6I$y0hF;3^NUpRx5IIl$K&h8erQr2L$sNKnn|MSxGZi*w%Ai*T^F}&R`n$DDt;dA{~==0G{%+TuNA_? zwj78U7-!}2rfH{T&*k8Oul72YRfP}QK0_?B+sV)3+4gb<@5f<<0Dv zsRKWGKkjZ{srmL@LD#`~=hx)3Z&_C6_+g zd$0J|c`rZeQ>Uss^Tgi31u=2oU8T(pSM#m3Y`d|2TkY_OkmG|c7A`aqp zS8ne_2lG2`xt2`W=8`YpEs(O{^jweYwa=}2myK!M8(AN8Y;M#_^Y10O3i0!1j#7}j z(6oAHZ)NFeF3+s-p9i0rwW-KGc0|C9hiUF_r4B89Q24ZzVRNC_vFWtucFnN5ZR>|6 zOfVi)$|$&cVU4y`$H%heDm=L>bL3TR_Doq78FT(6i)M3X`mQ7O=hU{}o$MrG zx!{rIElr!XZbAxXj)t4m=3W~-qhU_zE0q;IDm!QFJ$7L7D0BBknU+?oy*Cz}zI*w> zxg_u6{4A?kMFJxquUvab-LGPmo0Q?$lgHO34NG~Mc3*DP7D;J2`_-mGM-N1=y|K!B zO__7Ry`3+K-JY1)}jEX5i_GRhE!`k`t)Q;oR09R%#<9bDPohy3-?|*^e|YlIoC3-R$bbv zxpz~%g!TjelCMiH*UH7dGS?eOZ?jVC!5sIl}h`KNz|@d`p$Mu+-zC#6dm5( z3IhVW2CJ4F^N{&e+@RYd_u=KF^oUU&NdjAAI^#Q<NRfg!`!2Ow^BJf zOj?E}HFoUncs+Q&*j>KHuD7F~&ej;OpOm06aoq%~Lyq3H93ppT=68PH_p1EK;>?KS zpAx^D+g_dj;lRolKBGO9r>|UA7;B!T|0FqQev^S5&l2^*BmvpU7e-}>xp|y8nI*r* zVb;E_N4A~Q{TMxXP~3*~;@{=W&K_(?XSZ{K8rq=5-k%on!CX{v3s6dbMv{w4d3oQD=|8#p|$-@j%Ll1 zK@*IM_rEd7OgH^zeD<4H+0G4y9QvFW8C?!<*ZPgwHM)7z=qUj=)&?%z*y6iFyj-}z z@AwJ5O%|8VnjUUAJ4MX;L0h$UZh(X<=lOfz-v_E6$&N0WM44)5JLDt6b7oLI?}*;Azl zx;J>(wPZiKyTtGg&)TQ6R;)dEXr^?1cIM4fgZX`?jPM`zDWLsTqQc8lj0!G}>b4l) z=BKNq=5ov%Ew;$XmuLMvku_g#-7+&+eDq}FiiT_9Cd#eHx5T{Ldw?DaF$8ry^%)0NE&vlP^MbzM%GaNJAY zrIUI&?^0DuNJ>@ufu7|>?^>20>F5k~_I<*U64Kj~czTwv+M5A~POHB&*4a^+c2F&8 zmBV|f(GR9w+R@$m?VEvk`x*%`Wnr%LQ{`8OwK#C!xu3n-TWrCR7oC$|hR!*>?RcW? z7438HFP%2K

y2%D`EkutC#5qcdQP^usjju5A?>EK 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 + + + + + + + + انجمن علمی کامپیوتر گیلان - Guilan ACE + + + + + + + + + +

+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..142bcf0 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,33 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Enable gzip compression + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Handle Next.js static export + location / { + try_files $uri $uri.html $uri/ /index.html; + } + + # Cache static assets + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # Disable access to hidden files + location ~ /\. { + deny all; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..b37b414 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,8556 @@ +{ + "name": "vite_react_shadcn_ts", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "vite_react_shadcn_ts", + "version": "0.0.0", + "dependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-aspect-ratio": "^1.1.7", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-context-menu": "^2.2.15", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-hover-card": "^1.1.14", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-menubar": "^1.1.15", + "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.7", + "@radix-ui/react-scroll-area": "^1.2.9", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.5", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-toast": "^1.2.14", + "@radix-ui/react-toggle": "^1.1.9", + "@radix-ui/react-toggle-group": "^1.1.10", + "@radix-ui/react-tooltip": "^1.2.7", + "@tanstack/react-query": "^5.83.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.6.0", + "html2canvas": "^1.4.1", + "input-otp": "^1.4.2", + "jspdf": "^2.5.1", + "lucide-react": "^0.462.0", + "next-themes": "^0.3.0", + "react": "^18.3.1", + "react-day-picker": "^8.10.1", + "react-dom": "^18.3.1", + "react-helmet-async": "^2.0.5", + "react-hook-form": "^7.61.1", + "react-markdown": "^9.0.3", + "react-qr-code": "^2.0.11", + "react-resizable-panels": "^2.1.9", + "react-router-dom": "^6.30.1", + "recharts": "^2.15.4", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.0", + "sonner": "^1.7.4", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7", + "vaul": "^0.9.9", + "zod": "^3.25.76" + }, + "devDependencies": { + "@eslint/js": "^9.32.0", + "@tailwindcss/typography": "^0.5.16", + "@types/node": "^22.16.5", + "@types/react": "^18.3.26", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react-swc": "^3.11.0", + "autoprefixer": "^10.4.21", + "eslint": "^9.32.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^15.15.0", + "lovable-tagger": "^1.1.10", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "typescript": "^5.8.3", + "typescript-eslint": "^8.38.0", + "vite": "^5.4.19" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.9.tgz", + "integrity": "sha512-aI3jjAAO1fh7vY/pBGsn1i9LDbRP43+asrRlkPuTXW5yHXtd1NgTEMudbBoDDxrf1daEEfPJqR+JBMakzrR4Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.25.9" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.2.tgz", + "integrity": "sha512-KHp2IflsnGywDjBWDkR9iEqiWSpc8GIi0lgTT3mOElT0PP1tG26P4tmFI2YvAdzgq9RGyoHZQEIEdZy6Ec5xCA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.9.tgz", + "integrity": "sha512-OwS2CM5KocvQ/k7dFJa8i5bNGJP0hXWfVCfDkqRFP1IreH1JDC7wG6eCYCi0+McbfT8OR/kNqsI0UU0xP9H6PQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.0.tgz", + "integrity": "sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.0.tgz", + "integrity": "sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", + "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.6", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", + "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", + "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.32.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.32.0.tgz", + "integrity": "sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz", + "integrity": "sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.15.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.2.tgz", + "integrity": "sha512-wNB5ooIKHQc+Kui96jE/n69rHFWAVoxn5CAzL1Xdd8FG03cgY3MLO+GF9U3W737fYDSgPWA6MReKhBQBop6Pcw==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.2.tgz", + "integrity": "sha512-7cfaOQuCS27HD7DX+6ib2OrnW+b4ZBwDNnCcT0uTyidcmyWb03FnQqJybDBoCnpdxwBSfA94UAYlRCt7mV+TbA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.2", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.4.tgz", + "integrity": "sha512-JbbpPhp38UmXDDAu60RJmbeme37Jbgsm7NrHGgzYYFKmblzRUh6Pa641dII6LsjwF4XlScDrde2UAzDo/b9KPw==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.2" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", + "integrity": "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==", + "license": "MIT", + "peerDependencies": { + "react-hook-form": "^7.0.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.6", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", + "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.3.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", + "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.2.tgz", + "integrity": "sha512-XnbHrrprsNqZKQhStrSwgRUQzoCI1glLzdw79xiZPoofhGICeZRSQ3dIxAKH1gb3OHfNf4d6f+vAv3kil2eggA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.11.tgz", + "integrity": "sha512-l3W5D54emV2ues7jjeG1xcyN7S3jnK3zE2zHqgn0CmMsy9lNJwmgcrmaxS+7ipw15FAivzKNzH3d5EcGoFKw0A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collapsible": "1.1.11", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-alert-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.14.tgz", + "integrity": "sha512-IOZfZ3nPvN6lXpJTBCunFQPRSvK8MDgSc1FB85xnIpUKOw9en0dJj8JmCAxV7BiZdtYlUpmrQjoTFkVYtdoWzQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dialog": "1.1.14", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-aspect-ratio": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-aspect-ratio/-/react-aspect-ratio-1.1.7.tgz", + "integrity": "sha512-Yq6lvO9HQyPwev1onK1daHCHqXVLzPhSVjmsNjCa2Zcxy2f7uJD2itDtxknv6FzAKCwD1qQkeVDmX/cev13n/g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-avatar": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-avatar/-/react-avatar-1.1.10.tgz", + "integrity": "sha512-V8piFfWapM5OmNCXTzVQY+E1rDa53zY+MQ4Y7356v4fFz6vqCyUtIz2rUD44ZEdwg78/jKmMJHj07+C/Z/rcog==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-is-hydrated": "0.1.0", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.2.tgz", + "integrity": "sha512-yd+dI56KZqawxKZrJ31eENUwqc1QSqg4OZ15rybGjF2ZNwMO+wCyHzAVLRp9qoYJf7kYy0YpZ2b0JCzJ42HZpA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.11.tgz", + "integrity": "sha512-2qrRsVGSCYasSz1RFOorXwl0H7g7J1frQtgpQgYrt+MOidtPAINHn9CPovQXb83r8ahapdx3Tu0fa/pdFFSdPg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.15.tgz", + "integrity": "sha512-UsQUMjcYTsBjTSXw0P3GO0werEQvUY2plgRQuKoCTtkNr45q1DiL51j4m7gxhABzZ0BadoXNsIbg7F3KwiUBbw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.14.tgz", + "integrity": "sha512-+CpweKjqpzTmwRwcYECQcNYbI8V9VSQt0SNFKeEBLgfucbsLssU6Ppq7wUdNXEGb573bMjFhVjKVll8rmV6zMw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.10.tgz", + "integrity": "sha512-IM1zzRV4W3HtVgftdQiiOmA0AdJlCtMLe00FXaHwgt3rAnNsIyDqshvkIW3hj/iu5hu8ERP7KIYki6NkqDxAwQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-escape-keydown": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.15.tgz", + "integrity": "sha512-mIBnOjgwo9AH3FyKaSWoSu/dYj6VdhJ7frEPiGTeXCdUFHjl9h3mFh2wwhEtINOmYXWhdpf1rY2minFsmaNgVQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.2.tgz", + "integrity": "sha512-fyjAACV62oPV925xFCrH8DR5xWhg9KYtJT4s3u54jxp+L/hbpTY2kIeEFFbFe+a/HCE94zGQMZLIpVTPVZDhaA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-hover-card": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-hover-card/-/react-hover-card-1.1.14.tgz", + "integrity": "sha512-CPYZ24Mhirm+g6D8jArmLzjYu4Eyg3TTUHswR26QgzXBHBe64BO/RHOJKzmF/Dxb4y4f9PKyJdwm/O/AhNkb+Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.15.tgz", + "integrity": "sha512-tVlmA3Vb9n8SZSd+YSbuFR66l87Wiy4du+YE+0hzKQEANA+7cWKH1WgqcEX4pXqxUFQKrWQGHdvEfw00TjFiew==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menubar": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menubar/-/react-menubar-1.1.15.tgz", + "integrity": "sha512-Z71C7LGD+YDYo3TV81paUs8f3Zbmkvg6VLRQpKYfzioOE6n7fOhA3ApK/V/2Odolxjoc4ENk8AYCjohCNayd5A==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-menu": "2.1.15", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-navigation-menu": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-navigation-menu/-/react-navigation-menu-1.2.13.tgz", + "integrity": "sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.14.tgz", + "integrity": "sha512-ODz16+1iIbGUfFEfKx2HTPKizg2MN39uIOV8MXeHnmdd3i/N9Wt7vU46wbHsqA0xoaQyXVcs0KIlBdOA2Y95bw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.7.tgz", + "integrity": "sha512-IUFAccz1JyKcf/RjB552PlWwxjeCJB8/4KxT7EhBHOJM+mN7LdW+B3kacJXILm32xawcMMjb2i0cIZpo+f9kiQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-rect": "1.1.1", + "@radix-ui/react-use-size": "1.1.1", + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.4.tgz", + "integrity": "sha512-ueDqRbdc4/bkaQT3GIpLQssRlFgWaL/U2z/S31qRwwLWoxHLgry3SIfCwhxeQNbirEUXFa+lq3RL3oBYXtcmIA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-progress": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-progress/-/react-progress-1.1.7.tgz", + "integrity": "sha512-vPdg/tF6YC/ynuBIJlk1mm7Le0VgW6ub6J2UWnTQ7/D23KXcPI1qy+0vBkgKgd38RCMJavBXpB83HPNFMTb0Fg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-radio-group": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.3.7.tgz", + "integrity": "sha512-9w5XhD0KPOrm92OTTE0SysH3sYzHsSTHNvZgUBo/VZ80VdYyB5RneDbc0dKpURS24IxkoFRu/hI0i4XyfFwY6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.10.tgz", + "integrity": "sha512-dT9aOXUen9JSsxnMPv/0VqySQf5eDQ6LCk5Sw28kamz8wSOW2bJdlX2Bg5VUIIcV+6XlHpWTIuTPCf/UNIyq8Q==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-scroll-area": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-scroll-area/-/react-scroll-area-1.2.9.tgz", + "integrity": "sha512-YSjEfBXnhUELsO2VzjdtYYD4CfQjvao+lhhrX5XsHD7/cyUNzljF1FHEbgTPN7LH2MClfwRMIsYlqTYpKTTe2A==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.5.tgz", + "integrity": "sha512-HnMTdXEVuuyzx63ME0ut4+sEMYW6oouHWNGUZc7ddvUWIcfCva/AMoqEW/3wnEllriMWBa0RHspCYnfCWJQYmA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-focus-guards": "1.1.2", + "@radix-ui/react-focus-scope": "1.1.7", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.6.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.7.tgz", + "integrity": "sha512-0HEb8R9E8A+jZjvmFCy/J4xhbXy3TV+9XSnGJ3KvTtjlIUy/YQ/p6UYZvi7YbeoeXdyU9+Y3scizK6hkY37baA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.3.5.tgz", + "integrity": "sha512-rkfe2pU2NBAYfGaxa3Mqosi7VZEWX5CxKaanRv0vZd4Zhl9fvQrg0VM93dv3xGLGfrHuoTRF3JXH8nb9g+B3fw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.1", + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-switch": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.5.tgz", + "integrity": "sha512-5ijLkak6ZMylXsaImpZ8u4Rlf5grRmoc0p0QeX9VJtlrM4f5m3nCTX8tWga/zOA8PZYIR/t0p2Mnvd7InrJ6yQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-previous": "1.1.1", + "@radix-ui/react-use-size": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tabs": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.12.tgz", + "integrity": "sha512-GTVAlRVrQrSw3cEARM0nAx73ixrWDPNZAruETn3oHCNP6SbZ/hNxdxp+u7VkIEv3/sFoLq1PfcHrl7Pnp0CDpw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toast": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toast/-/react-toast-1.2.14.tgz", + "integrity": "sha512-nAP5FBxBJGQ/YfUB+r+O6USFVkWq3gAInkxyEnmvEV5jtSbfDhfa4hwX8CraCnbjMLsE7XSf/K75l9xXY7joWg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.9.tgz", + "integrity": "sha512-ZoFkBBz9zv9GWer7wIjvdRxmh2wyc2oKWw6C6CseWd6/yq1DK/l5lJ+wnsmFwJZbBYqr02mrf8A2q/CVCuM3ZA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-toggle-group": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle-group/-/react-toggle-group-1.1.10.tgz", + "integrity": "sha512-kiU694Km3WFLTC75DdqgM/3Jauf3rD9wxeS9XtyWFKsBUeZA337lC+6uUazT7I1DhanZ5gyD5Stf8uf2dbQxOQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-roving-focus": "1.1.10", + "@radix-ui/react-toggle": "1.1.9", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.7.tgz", + "integrity": "sha512-Ap+fNYwKTYJ9pzqW+Xe2HtMRbQ/EeWkj2qykZ6SuEV4iS/o1bZI5ssJbk4D2r8XuDuOBVz/tIx2JObtuqU+5Zw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.10", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-popper": "1.2.7", + "@radix-ui/react-portal": "1.1.9", + "@radix-ui/react-presence": "1.1.4", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-slot": "1.2.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-visually-hidden": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-is-hydrated": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-is-hydrated/-/react-use-is-hydrated-0.1.0.tgz", + "integrity": "sha512-U+UORVEq+cTnRIaostJv9AGdV3G6Y+zbVd+12e18jQ5A3c0xL03IhnHuiU4UV69wolOQp5GfR58NW/EgdQhwOA==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.5.0" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@remix-run/router": { + "version": "1.23.0", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz", + "integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.24.0.tgz", + "integrity": "sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.24.0.tgz", + "integrity": "sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.24.0.tgz", + "integrity": "sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.24.0.tgz", + "integrity": "sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.24.0.tgz", + "integrity": "sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.24.0.tgz", + "integrity": "sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.24.0.tgz", + "integrity": "sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.24.0.tgz", + "integrity": "sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-powerpc64le-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.24.0.tgz", + "integrity": "sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.24.0.tgz", + "integrity": "sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.24.0.tgz", + "integrity": "sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.24.0.tgz", + "integrity": "sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.24.0.tgz", + "integrity": "sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.24.0.tgz", + "integrity": "sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.24.0.tgz", + "integrity": "sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.24.0.tgz", + "integrity": "sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@swc/core": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.2.tgz", + "integrity": "sha512-YWqn+0IKXDhqVLKoac4v2tV6hJqB/wOh8/Br8zjqeqBkKa77Qb0Kw2i7LOFzjFNZbZaPH6AlMGlBwNrxaauaAg==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.23" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.13.2", + "@swc/core-darwin-x64": "1.13.2", + "@swc/core-linux-arm-gnueabihf": "1.13.2", + "@swc/core-linux-arm64-gnu": "1.13.2", + "@swc/core-linux-arm64-musl": "1.13.2", + "@swc/core-linux-x64-gnu": "1.13.2", + "@swc/core-linux-x64-musl": "1.13.2", + "@swc/core-win32-arm64-msvc": "1.13.2", + "@swc/core-win32-ia32-msvc": "1.13.2", + "@swc/core-win32-x64-msvc": "1.13.2" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.13.2.tgz", + "integrity": "sha512-44p7ivuLSGFJ15Vly4ivLJjg3ARo4879LtEBAabcHhSZygpmkP8eyjyWxrH3OxkY1eRZSIJe8yRZPFw4kPXFPw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.13.2.tgz", + "integrity": "sha512-Lb9EZi7X2XDAVmuUlBm2UvVAgSCbD3qKqDCxSI4jEOddzVOpNCnyZ/xEampdngUIyDDhhJLYU9duC+Mcsv5Y+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.13.2.tgz", + "integrity": "sha512-9TDe/92ee1x57x+0OqL1huG4BeljVx0nWW4QOOxp8CCK67Rpc/HHl2wciJ0Kl9Dxf2NvpNtkPvqj9+BUmM9WVA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.13.2.tgz", + "integrity": "sha512-KJUSl56DBk7AWMAIEcU83zl5mg3vlQYhLELhjwRFkGFMvghQvdqQ3zFOYa4TexKA7noBZa3C8fb24rI5sw9Exg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.13.2.tgz", + "integrity": "sha512-teU27iG1oyWpNh9CzcGQ48ClDRt/RCem7mYO7ehd2FY102UeTws2+OzLESS1TS1tEZipq/5xwx3FzbVgiolCiQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.13.2.tgz", + "integrity": "sha512-dRPsyPyqpLD0HMRCRpYALIh4kdOir8pPg4AhNQZLehKowigRd30RcLXGNVZcc31Ua8CiPI4QSgjOIxK+EQe4LQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.13.2.tgz", + "integrity": "sha512-CCxETW+KkYEQDqz1SYC15YIWYheqFC+PJVOW76Maa/8yu8Biw+HTAcblKf2isrlUtK8RvrQN94v3UXkC2NzCEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.13.2.tgz", + "integrity": "sha512-Wv/QTA6PjyRLlmKcN6AmSI4jwSMRl0VTLGs57PHTqYRwwfwd7y4s2fIPJVBNbAlXd795dOEP6d/bGSQSyhOX3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.13.2.tgz", + "integrity": "sha512-PuCdtNynEkUNbUXX/wsyUC+t4mamIU5y00lT5vJcAvco3/r16Iaxl5UCzhXYaWZSNVZMzPp9qN8NlSL8M5pPxw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.13.2.tgz", + "integrity": "sha512-qlmMkFZJus8cYuBURx1a3YAG2G7IW44i+FEYV5/32ylKkzGNAr9tDJSA53XNnNXkAB5EXSPsOz7bn5C3JlEtdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.23", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.23.tgz", + "integrity": "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@tailwindcss/typography": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", + "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.castarray": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.merge": "^4.6.2", + "postcss-selector-parser": "6.0.10" + }, + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" + } + }, + "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { + "version": "6.0.10", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.0.10.tgz", + "integrity": "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w==", + "dev": true, + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.83.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.83.0.tgz", + "integrity": "sha512-0M8dA+amXUkyz5cVUm/B+zSk3xkQAcuXuz5/Q/LveT4ots2rBpPTZOzd7yJa2Utsf8D2Upl5KyjhHRY+9lB/XA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.83.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.83.0.tgz", + "integrity": "sha512-/XGYhZ3foc5H0VM2jLSD/NyBRIOK4q9kfeml4+0x2DlL6xVuAcVEW+hTlTapAmejObg0i3eNqhkr2dT+eciwoQ==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.83.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", + "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", + "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", + "integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==" + }, + "node_modules/@types/node": { + "version": "22.16.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.16.5.tgz", + "integrity": "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.13", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", + "integrity": "sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==", + "license": "MIT" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "optional": true + }, + "node_modules/@types/react": { + "version": "18.3.26", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.26.tgz", + "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz", + "integrity": "sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.10.0", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/type-utils": "8.38.0", + "@typescript-eslint/utils": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", + "graphemer": "^1.4.0", + "ignore": "^7.0.0", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.38.0", + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.38.0.tgz", + "integrity": "sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.38.0.tgz", + "integrity": "sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.38.0", + "@typescript-eslint/types": "^8.38.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz", + "integrity": "sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz", + "integrity": "sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz", + "integrity": "sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/utils": "8.38.0", + "debug": "^4.3.4", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.38.0.tgz", + "integrity": "sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz", + "integrity": "sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.38.0", + "@typescript-eslint/tsconfig-utils": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/visitor-keys": "8.38.0", + "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", + "minimatch": "^9.0.4", + "semver": "^7.6.0", + "ts-api-utils": "^2.1.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.38.0.tgz", + "integrity": "sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.38.0", + "@typescript-eslint/types": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz", + "integrity": "sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.38.0", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==" + }, + "node_modules/@vitejs/plugin-react-swc": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-3.11.0.tgz", + "integrity": "sha512-YTJCGFdNMHCMfjODYtxRNVAYmTWQ1Lb8PulP/2/f/oEEtglw8oKxKIZmmRkyXrVrHfsKOaVkAc3NT9/dMutO5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "1.0.0-beta.27", + "@swc/core": "^1.12.11" + }, + "peerDependencies": { + "vite": "^4 || ^5 || ^6 || ^7" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz", + "integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.4.tgz", + "integrity": "sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "bin": { + "atob": "bin/atob.js" + }, + "engines": { + "node": ">= 4.5.0" + } + }, + "node_modules/autoprefixer": { + "version": "10.4.21", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.21.tgz", + "integrity": "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.24.4", + "caniuse-lite": "^1.0.30001702", + "fraction.js": "^4.3.7", + "normalize-range": "^0.1.2", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.25.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.25.1.tgz", + "integrity": "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001726", + "electron-to-chromium": "^1.5.173", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/btoa": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/btoa/-/btoa-1.2.1.tgz", + "integrity": "sha512-SB4/MIGlsiVkMcHmT+pSmIPoNDoHg+7cMzmt3Uxt628MTz2487DKSqK/fuhFBrkuqrYv5UCEnACpF4dTFNKc/g==", + "bin": { + "btoa": "bin/btoa.js" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001727", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001727.tgz", + "integrity": "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/cmdk": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cmdk/-/cmdk-1.1.1.tgz", + "integrity": "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "^1.1.1", + "@radix-ui/react-dialog": "^1.1.6", + "@radix-ui/react-id": "^1.1.0", + "@radix-ui/react-primitive": "^2.0.2" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "react-dom": "^18 || ^19 || ^19.0.0-rc" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/core-js": { + "version": "3.47.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.47.0.tgz", + "integrity": "sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==", + "hasInstallScript": true, + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", + "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", + "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dompurify": { + "version": "2.5.8", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-2.5.8.tgz", + "integrity": "sha512-o1vSNgrmYMQObbSSvF/1brBYEQPHhV1+gsmrusO7/GXtp1T9rCS8cXFqVxK/9crT1jA6Ccv+5MTSjBNqr7Sovw==", + "optional": true + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.192", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.192.tgz", + "integrity": "sha512-rP8Ez0w7UNw/9j5eSXCe10o1g/8B1P5SM90PCCMVkIRQn2R0LEHWz4Eh9RnxkniuDe1W0cTSOB3MLlkTGDcuCg==", + "dev": true, + "license": "ISC" + }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.32.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.32.0.tgz", + "integrity": "sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.0", + "@eslint/core": "^0.15.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.32.0", + "@eslint/plugin-kit": "^0.3.4", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "@types/json-schema": "^7.0.15", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", + "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.20", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", + "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", + "integrity": "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", + "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", + "integrity": "sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==", + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.4" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", + "integrity": "sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==", + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fflate": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", + "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==" + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.1.tgz", + "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fraction.js": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-4.3.7.tgz", + "integrity": "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "patreon", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-raw": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.1.0.tgz", + "integrity": "sha512-Y8/SBAHkZGoNkpzqqfCldijcuUKh7/su31kEBp67cFY09Wy0mTRgtsLYsiIxMJxlu0f6AA5SUTbDR8K0rxnbUw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-sanitize": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/hast-util-sanitize/-/hast-util-sanitize-5.0.2.tgz", + "integrity": "sha512-3yTWghByc50aGS7JlGhk61SPenfE/p1oaFeNwkOOyrscaOkMGrcW9+Cy/QAIOBpZxP1yqDIzFMR0+Np0i0+usg==", + "dependencies": { + "@types/hast": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "unist-util-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", + "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-parse5/node_modules/property-information": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-6.5.0.tgz", + "integrity": "sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.4.tgz", + "integrity": "sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==" + }, + "node_modules/input-otp": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/input-otp/-/input-otp-1.4.2.tgz", + "integrity": "sha512-l3jWwYNvrEa6NTCt7BECfCm48GvwuZzkoeG3gBL2w4CHeOXW3eKFmf9UNYkNfYc3mxMrthMnxjIE07MT0zLBQA==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "1.21.6", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", + "integrity": "sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==", + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/jspdf": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-2.5.2.tgz", + "integrity": "sha512-myeX9c+p7znDWPk0eTrujCzNjT+CXdXyk7YmJq5nD5V7uLLKmSXnlQ/Jn/kuo3X09Op70Apm0rQSnFWyGK8uEQ==", + "dependencies": { + "@babel/runtime": "^7.23.2", + "atob": "^2.1.2", + "btoa": "^1.2.1", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.6", + "core-js": "^3.6.0", + "dompurify": "^2.5.4", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "license": "MIT" + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, + "node_modules/lodash.castarray": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", + "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", + "dev": true + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "dev": true + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lovable-tagger": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/lovable-tagger/-/lovable-tagger-1.1.10.tgz", + "integrity": "sha512-LbYaxi6vgrqg7Sq93/cRbIM78EP+X+GUU7spx804yqB2bxfiOej8UvcZHeE4WqMjAFI2dHGhXpy8r6SnvmrzGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.8", + "esbuild": "^0.25.0", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12", + "tailwindcss": "^3.4.17" + }, + "peerDependencies": { + "vite": ">=5.0.0 <8.0.0" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/aix-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.0.tgz", + "integrity": "sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/android-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.0.tgz", + "integrity": "sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/android-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.0.tgz", + "integrity": "sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/android-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.0.tgz", + "integrity": "sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/darwin-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.0.tgz", + "integrity": "sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/darwin-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.0.tgz", + "integrity": "sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.0.tgz", + "integrity": "sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/freebsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.0.tgz", + "integrity": "sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-arm": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.0.tgz", + "integrity": "sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.0.tgz", + "integrity": "sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.0.tgz", + "integrity": "sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-loong64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.0.tgz", + "integrity": "sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-mips64el": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.0.tgz", + "integrity": "sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-ppc64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.0.tgz", + "integrity": "sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-riscv64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.0.tgz", + "integrity": "sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-s390x": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.0.tgz", + "integrity": "sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/linux-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.0.tgz", + "integrity": "sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/netbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.0.tgz", + "integrity": "sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/openbsd-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.0.tgz", + "integrity": "sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/sunos-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.0.tgz", + "integrity": "sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/win32-arm64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.0.tgz", + "integrity": "sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/win32-ia32": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.0.tgz", + "integrity": "sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/@esbuild/win32-x64": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.0.tgz", + "integrity": "sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/lovable-tagger/node_modules/esbuild": { + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz", + "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.0", + "@esbuild/android-arm": "0.25.0", + "@esbuild/android-arm64": "0.25.0", + "@esbuild/android-x64": "0.25.0", + "@esbuild/darwin-arm64": "0.25.0", + "@esbuild/darwin-x64": "0.25.0", + "@esbuild/freebsd-arm64": "0.25.0", + "@esbuild/freebsd-x64": "0.25.0", + "@esbuild/linux-arm": "0.25.0", + "@esbuild/linux-arm64": "0.25.0", + "@esbuild/linux-ia32": "0.25.0", + "@esbuild/linux-loong64": "0.25.0", + "@esbuild/linux-mips64el": "0.25.0", + "@esbuild/linux-ppc64": "0.25.0", + "@esbuild/linux-riscv64": "0.25.0", + "@esbuild/linux-s390x": "0.25.0", + "@esbuild/linux-x64": "0.25.0", + "@esbuild/netbsd-arm64": "0.25.0", + "@esbuild/netbsd-x64": "0.25.0", + "@esbuild/openbsd-arm64": "0.25.0", + "@esbuild/openbsd-x64": "0.25.0", + "@esbuild/sunos-x64": "0.25.0", + "@esbuild/win32-arm64": "0.25.0", + "@esbuild/win32-ia32": "0.25.0", + "@esbuild/win32-x64": "0.25.0" + } + }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" + }, + "node_modules/lucide-react": { + "version": "0.462.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.462.0.tgz", + "integrity": "sha512-NTL7EbAao9IFtuSivSZgrAh4fZd09Lr+6MTkqIxuHaH2nnYiYIzXPo06cOxHg9wKLdj6LL8TByG4qpePqwgx/g==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/magic-string": { + "version": "0.30.12", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz", + "integrity": "sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-find-and-replace/node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.2.tgz", + "integrity": "sha512-uZhTV/8NBuw0WHkPTrCqDOl0zVe1BIng5ZtHoDk49ME1qqcjYmmLmOf0gELgcRMxN4w2iuIeVso5/6QymSrgmA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz", + "integrity": "sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ] + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next-themes": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/next-themes/-/next-themes-0.3.0.tgz", + "integrity": "sha512-/QHIrsYpd6Kfk7xakK4svpDI5mmXP0gfvCoJdGpZQ2TOrQZmsW0QxjaiLn8wbIKjtm4BTSqLoix4lxYYOnLJ/w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8 || ^17 || ^18", + "react-dom": "^16.8 || ^17 || ^18" + } + }, + "node_modules/node-releases": { + "version": "2.0.19", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", + "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "license": "MIT" + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", + "integrity": "sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.0.1.tgz", + "integrity": "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw==", + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", + "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.0.0", + "yaml": "^2.3.4" + }, + "engines": { + "node": ">= 14" + }, + "peerDependencies": { + "postcss": ">=8.0.9", + "ts-node": ">=9.0.0" + }, + "peerDependenciesMeta": { + "postcss": { + "optional": true + }, + "ts-node": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/qr.js": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", + "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==" + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-day-picker": { + "version": "8.10.1", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-8.10.1.tgz", + "integrity": "sha512-TMx7fNbhLk15eqcMt+7Z7S2KF7mfTId/XJDjKE8f+IUcFn0l08/kI4FiYTL/0yuOLmEcbR4Fwe3GJf/NiiMnPA==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "date-fns": "^2.28.0 || ^3.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" + }, + "node_modules/react-helmet-async": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/react-helmet-async/-/react-helmet-async-2.0.5.tgz", + "integrity": "sha512-rYUYHeus+i27MvFE+Jaa4WsyBKGkL6qVgbJvSBoX8mbsWoABJXdEO0bZyi0F6i+4f0NuIb8AvqPMj3iXFHkMwg==", + "dependencies": { + "invariant": "^2.2.4", + "react-fast-compare": "^3.2.2", + "shallowequal": "^1.1.0" + }, + "peerDependencies": { + "react": "^16.6.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-hook-form": { + "version": "7.61.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.61.1.tgz", + "integrity": "sha512-2vbXUFDYgqEgM2RcXcAT2PwDW/80QARi+PKmHy5q2KhuKvOlG8iIYgf7eIlIANR5trW9fJbP4r5aub3a4egsew==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-qr-code": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz", + "integrity": "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==", + "dependencies": { + "prop-types": "^15.8.1", + "qr.js": "0.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.1.tgz", + "integrity": "sha512-HpMh8+oahmIdOuS5aFKKY6Pyog+FNaZV/XyJOq7b4YFwsFHe5yYfdbIalI4k3vU2nSDql7YskmUseHsRrJqIPA==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-resizable-panels": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-2.1.9.tgz", + "integrity": "sha512-z77+X08YDIrgAes4jl8xhnUu1LNIRp4+E7cv4xHmLOxxUPO/ML7PSrE813b90vj7xvQ1lcf7g2uA9GeMZonjhQ==", + "license": "MIT", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/react-router": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz", + "integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.1", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz", + "integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.0", + "react-router": "6.30.1" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==", + "optional": true + }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rehype-sanitize": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/rehype-sanitize/-/rehype-sanitize-6.0.0.tgz", + "integrity": "sha512-CsnhKNsyI8Tub6L4sm5ZFsme4puGfc6pYylvXo1AeqaGbjOYyzNv3qZPwvs0oMJ39eryyeOdmxwUIo94IpEhqg==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-sanitize": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve": { + "version": "1.22.8", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", + "integrity": "sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==", + "license": "MIT", + "dependencies": { + "is-core-module": "^2.13.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.0.4.tgz", + "integrity": "sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==", + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/rollup": { + "version": "4.24.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.24.0.tgz", + "integrity": "sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.6" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.24.0", + "@rollup/rollup-android-arm64": "4.24.0", + "@rollup/rollup-darwin-arm64": "4.24.0", + "@rollup/rollup-darwin-x64": "4.24.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.24.0", + "@rollup/rollup-linux-arm-musleabihf": "4.24.0", + "@rollup/rollup-linux-arm64-gnu": "4.24.0", + "@rollup/rollup-linux-arm64-musl": "4.24.0", + "@rollup/rollup-linux-powerpc64le-gnu": "4.24.0", + "@rollup/rollup-linux-riscv64-gnu": "4.24.0", + "@rollup/rollup-linux-s390x-gnu": "4.24.0", + "@rollup/rollup-linux-x64-gnu": "4.24.0", + "@rollup/rollup-linux-x64-musl": "4.24.0", + "@rollup/rollup-win32-arm64-msvc": "4.24.0", + "@rollup/rollup-win32-ia32-msvc": "4.24.0", + "@rollup/rollup-win32-x64-msvc": "4.24.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "7.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", + "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/sonner": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-1.7.4.tgz", + "integrity": "sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/style-to-js": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.18.tgz", + "integrity": "sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg==", + "dependencies": { + "style-to-object": "1.0.11" + } + }, + "node_modules/style-to-object": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.11.tgz", + "integrity": "sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow==", + "dependencies": { + "inline-style-parser": "0.2.4" + } + }, + "node_modules/sucrase": { + "version": "3.35.0", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.0.tgz", + "integrity": "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "glob": "^10.3.10", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.0.tgz", + "integrity": "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.6", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tailwindcss-animate": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/tailwindcss-animate/-/tailwindcss-animate-1.0.7.tgz", + "integrity": "sha512-bl6mpH3T7I3UFxuvDEXLxy/VuFxBk5bbzplh7tXI68mwMokNYd1t9qPBHlnyTwfa4JGC4zP516I1hYYtQ/vspA==", + "license": "MIT", + "peerDependencies": { + "tailwindcss": ">=3.0.0 || insiders" + } + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-api-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.0.tgz", + "integrity": "sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "5.8.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", + "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.38.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.38.0.tgz", + "integrity": "sha512-FsZlrYK6bPDGoLeZRuvx2v6qrM03I0U0SnfCLPs/XCCPCFD80xU9Pg09H/K+XFa68uJuZo7l/Xhs+eDRg2l3hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.38.0", + "@typescript-eslint/parser": "8.38.0", + "@typescript-eslint/typescript-estree": "8.38.0", + "@typescript-eslint/utils": "8.38.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0", + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", + "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.0.0.tgz", + "integrity": "sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", + "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", + "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sync-external-store": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", + "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/vaul": { + "version": "0.9.9", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-0.9.9.tgz", + "integrity": "sha512-7afKg48srluhZwIkaU+lgGtFCUsYBSGOl8vcc8N/M3YQlZFlynHD15AE+pwrYdc826o7nrIND4lL9Y6b9WWZZQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0", + "react-dom": "^16.8 || ^17.0 || ^18.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/yaml": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.0.tgz", + "integrity": "sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..19325c1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,91 @@ +{ + "name": "vite_react_shadcn_ts", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "build:dev": "vite build --mode development", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^3.10.0", + "@radix-ui/react-accordion": "^1.2.11", + "@radix-ui/react-alert-dialog": "^1.1.14", + "@radix-ui/react-aspect-ratio": "^1.1.7", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.2", + "@radix-ui/react-collapsible": "^1.1.11", + "@radix-ui/react-context-menu": "^2.2.15", + "@radix-ui/react-dialog": "^1.1.14", + "@radix-ui/react-dropdown-menu": "^2.1.15", + "@radix-ui/react-hover-card": "^1.1.14", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-menubar": "^1.1.15", + "@radix-ui/react-navigation-menu": "^1.2.13", + "@radix-ui/react-popover": "^1.1.14", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.7", + "@radix-ui/react-scroll-area": "^1.2.9", + "@radix-ui/react-select": "^2.2.5", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.5", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.5", + "@radix-ui/react-tabs": "^1.1.12", + "@radix-ui/react-toast": "^1.2.14", + "@radix-ui/react-toggle": "^1.1.9", + "@radix-ui/react-toggle-group": "^1.1.10", + "@radix-ui/react-tooltip": "^1.2.7", + "@tanstack/react-query": "^5.83.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "date-fns": "^3.6.0", + "embla-carousel-react": "^8.6.0", + "input-otp": "^1.4.2", + "lucide-react": "^0.462.0", + "next-themes": "^0.3.0", + "react": "^18.3.1", + "react-day-picker": "^8.10.1", + "react-dom": "^18.3.1", + "react-helmet-async": "^2.0.5", + "react-hook-form": "^7.61.1", + "react-markdown": "^9.0.3", + "react-resizable-panels": "^2.1.9", + "react-router-dom": "^6.30.1", + "recharts": "^2.15.4", + "rehype-raw": "^7.0.0", + "rehype-sanitize": "^6.0.0", + "remark-gfm": "^4.0.0", + "sonner": "^1.7.4", + "tailwind-merge": "^2.6.0", + "tailwindcss-animate": "^1.0.7", + "vaul": "^0.9.9", + "zod": "^3.25.76", + "react-qr-code": "^2.0.11", + "jspdf": "^2.5.1", + "html2canvas": "^1.4.1" + }, + "devDependencies": { + "@eslint/js": "^9.32.0", + "@tailwindcss/typography": "^0.5.16", + "@types/node": "^22.16.5", + "@types/react": "^18.3.26", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react-swc": "^3.11.0", + "autoprefixer": "^10.4.21", + "eslint": "^9.32.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^15.15.0", + "lovable-tagger": "^1.1.10", + "postcss": "^8.5.6", + "tailwindcss": "^3.4.17", + "typescript": "^5.8.3", + "typescript-eslint": "^8.38.0", + "vite": "^5.4.19" + } +} diff --git a/frontend/postcss.config.js b/frontend/postcss.config.js new file mode 100644 index 0000000..2aa7205 --- /dev/null +++ b/frontend/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/frontend/public/enamad.png b/frontend/public/enamad.png new file mode 100644 index 0000000000000000000000000000000000000000..77360ccf7a5cc5f0cc2593d4efdd8e793298247b GIT binary patch literal 4239 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 z�Dp61Ut___Itm?Fzx@u|lIIwU($1iRKu$Te#x%#4rE&)tt6*h14361qvJC(in3Y z){$CA)t~3XpT3!2`Oq%3=%G$%Vu?zLldO9FTrVn5dB3L#kf*R4Jwand1+2|ZZt8t5 z0Zo5Ta`R*g%667tY5G|waeT+VAaxYcMC!^qMv@iOm!xzCOGox`#%an`=WoR78Z{$H zzn`qwS2-Wrusqw}92driXKw!7XK%krdObuUDv=NzPD_p|!%S1)PQlY9pNqemNahK{ zA+LVfnHRkCJ6`f*7MIVCQOtY4q^1UbQ#7G09d|so%6mVtixmc_ciCUTT&}}tmiQLa zQwojMjGi^9MWR5^URJNw32KfaNnZSWitIA#K3NG2#QQuW&MhM9V1(XwLNlV*8}ZfMv;4E)zJUYdf?hR)rW{Q<6?Cm}aO&ghZ3xo^?nKkD z_Q@y64pK_F8;wERqA9aU@L3KnN>)mDc2qzi$P~ADa>sn!S90$`3{syWMI3eR?5B#t zJDM>!iVg`2NorXf>{z+s)3<(ZDdr)| zGj&Fj180~Sjr$tC&X!5EuoW8QnG+RLt;c*8|UrZ$j|@KxAAQ+*?_Ni zXQ?FTNLtc1LrQOWDe0=d2m$9EIy1*Zk00R={_9`!8-MT-zHrBrET=wxu!yS$C@!lH zOC-6LqygWF6_`!Y_0kpN+pc+%Xk)~cbGUL7fq8ZxT;isiZse{zZ(-wxInF%&EJ`yp zwpl074AkL6%6pS9u~g%%VOB?6dD(@?hO@Zs?p;jkMCppIh}=>C#IRI#O`_*VK?c^> zi!#Fiue2%Q$!rcwo)Cta=iA%DeGflQvGN#KU3M-5F(lUDpizgk4s`{!BuD{gLtgm8 zt2r_a-oTr$-wMNh z1ZF!c&pOwqT)`yvb1y87_@(#Wz>N>o^kx^b2t)$!Ggk3L?9gXYt~lABJCUHn8i+@a zHGw}G*(aRlK70%@<5&w1K108z3t&!Pg(ye6!l!cFQxh~veLM|HORE)7l1=1GmJT20 zW#>B1+_nKiO%R8ucRr?Q8jE0KziGqiXWx7Go-cj%p(pR}Ro<|G(9aD?JuU(?fFyVu z5M4`<2@(oU-!j|#@pr%Y9rFwR6{&85c=v>vn`o4sh)L<-;4twUfB7H}3^%jj$M{q; zkvSBhObv9c=SUBc4$OIy1>9VTbof|h^<D z+M$9EeEugNgub2I|Xk!KJ`<0=Lr;w%ugq0k(Jj;ll{9k)N|(6+}* zFWG{|BLsnw6AITufvOggI);76LfFqMFJI))k%F(>{TM|*Z+qT$dC^)iS~j_o#|1=E$PCU>mX_C)F+-NEFrSIH zg-n|rz5q2+NNA_(oE${a3|kqku!%i<|F>SmkA2tmY$?WQGlZgt1xINWWl~(oY8X=b ztoG6G69?P5;pQd&@89?s@B6@47&d+8<`z18)$W@j%I)_sSlGrn=U+%6iPS8UTz&uwsj%emuAmJq zrVf`9r=4{+7oL9xk3I4jd!IZ=Ik$-vV+Ias?p%uL+Vy@uYfM6e(V0A9oyBR$Ob7!s z;GKt5@$mg$!@I-rpgi(Boc*n?;9A^8JyOSy$qOizF=hhZLG-YI#l$m_pF+om_FPIf$J3(|s)F@LHJdNwD9iHwSp6(oNo#E26 zXGl#pq<2;pV5=g@$2ctKHVw|a@7~d8Z@YWXy;ZpgvV`*qv*Q*(XAu>l7LY=T5I-eCyk0d-W?62`S1R!A}I+Aufp(1&=Q$e)BK)ayayH-jiaDsmGbe5s&&DU zbzUPUH!C<=Ts(qWpC~1@K)&!(n86GUex zEO0-!BeRG+xobBn^CnSFF~tHxmFr%zLKBUqw}D&levJO?CN93@EL^ihX~NjHBCf@; zpRvtR7IPH!5(^u4@|F7!@W8I6b<*+AzD9PU1yUt*oLYg|+R+nO8a>V%UL5$PAA22h z{Srcp6$L>IlRy;{Q6eKRI6uSk;cfibuiwNyk37j@KQM8DXpTXv7$uPc(GkgI>Hm{C z?o+vjYm3oIHYSe$Jk@O15W7O9vkX>Ngcx#pOMS(`KSH19Tw9U%iBq_z&W1`7Sbir4ZYnt(wKKJ?CZ){^l;X}6p`E{S$ z{h^Xc=Vpn}n!Mohb1&Jpb@nwe&DnH; z&;MGN8q-nIL^>~sDcm&g`lT(4XJVm+yFjpZkU1;A1!3NpLf31!obL zQ+N@)bJT5%IDysuoHo<&|M}rJ^Ojd$hKvr9>bg@E?#T1&BJStHk?;5+=DXB8WxAD3 zqhCkX6e#do;3eQoN9#&P40!K<|2QAM;U0Xo36hXh2q{8rDYYavxsF#B%9f1_Ybo{d z=kQ#glL6u~Hg34QpQ|s~#LxWryVyGCh;2>NdFq-)2q8BERFTRUr;6YD{SWY!d-mgc z^Qdb&wv}V52#v+=ZuuAbf4SCjJ{CmSVd-7SF`lqQ;#RqqY=cI{gd_7 zpjQHD%DhytM1eGKW+hTcY>g`}JN*r( zpSEyLT~9>ScQR*3hug7p2W4up)Y9~nlq!nQuYTxzFL>+47jAoZ+e}17msMp<>~9uQ z8T^Fklt22b+qhxR*%VbtVJjU}pky%~CW+FxUWeEg====~y5(Vd+Q*J5Y4`WzXR{N;_05f>_6aMe~S z6ipEq`}p9g%y1fst|nHA7+2X~2YCGji?~tE7oS+6@h&T;aO{J0Jm7bF9j2oE&-msw zp=^6h3S+sKm~SFwN(2?m9Nx_kg999DiAHPiHPJbiY{1QTJ1!o~a{dLgr1S*lA^J_o zq)$%^DE2e)Tlv$EKg|7)AEcz^UV4({k~7 z^ZeFNp3Q~Z=djfkhD8D8fEG_LW$VinJT5g9b4&cjAMfIKKDdisZ!@usO#BKn?Ev5O zSS?DN4yicEs@qDGKBd)b*Wg5{`ugMidgpt>5JOJ*&!-*hKU+m;oDx+j1NfM53Cv3; zHj z+&Qc{3|@fboUziRxx%C>7d9R`^WFy@{_ETCS-r3Hvm8~1&LiUhO-Xpn_pqjA=SC@i z^xZH1w)vUj)x;{Q+7o8&h`?#8Ca!1Pb!Y?c`}ji)8>Ns;o;I@%zb%~uTNn8WAcix; z8MK-Wft*b7RvGZ@3`(+<m-&d}J-p@G)A>g~a6Q{MI8scHdrh8ZTr@#D1eB^WYP|a;ZU52xe+H89%F7N2c@EBebB9SCn&MQd? zsmPEU63Ibui6}+33q^CMY#li~M}PU;lNd=8rm>?59!(pPRiGTHvzk^H9U!++Y;(Q+wmuZNx!N&vvVpTGlJ&X5!VlV&c z|M@uhIq-&AlWU+>7ZC!gU$ zI`xfF%!GQf%3x-e>#o>Hk(LS8LtRmj?9sdyr3Ke7{Qf0A{kbpR{}n~EEZe%n=(xZI znMUSmhex>XMW>(l9dEnn@5MHpCvg#>9*Z{K;WwB?v@d+{u96SkxR1W9;7FM(Dfyvl z#ZOxu$W)I@j3OC62_sjM#No+{LK46{-V7fm6soi#fv<83Yb_EzHc7nc^;hsy-+c|I z_xBSgBa8__R;jeaHw7vsB*mwOL?X`i`QYcj$}jxtpL6@;P|lr&iO0HHp3{U+9!v?^ zB`FqBS|AdAB58Jpo9ff4CR%R92tb_+(vG^^$>fXc${l5+rVJ%2%&9}Vq*tVl`g8zQDzgCsH!8p{GxL>lD6`> zM~*NfDZAGBk`NjS$#qN2VdC?hNTOtnU_>cTdI6l?yw*kdJ{?Q5%HK~Ifs9Mc-Ee0P zjkCDuD0+!~&6vCIzmM&k7rAiffT|a9zD4^z?tP-*-~9HUaPQ-XDSHd~fuDY~9=m89 z|IunNDHE+TssKKD90e*FPuS3*cwP2y7V*?>Kph%3ZYlXs|KKfLd9G294paDfuqs=9 zYQm)&35H0Cn=SeCTMzJm{^s3`+eFzfa4uyuK`L`S*yZOTF9#k++wt$$J&`Av_>)O^ zr$6&_bwd3H^~0z8Js~p?b0JsOU0YdO+%o<#t4kL#VLq$1 zBMP9o%`a20SEiqpFL9#J0w#AKk|<{fDn|V0D&qW(JkR z6da+5ic2M=l2&qqrC^AN5GaXKV%Db&1B$2_vY@exR%cKPIGTL1B#=D_vX5}?7MB=B z25XzcCn!^*NK=;SG&;<3S$RpcjNMCy_kPXAt6w_qd2A0~cwoeZm%f@o-@2B$%zZ{C;iZz z@Lpe6iC;IIERkHzkP_eD!rsw`#-Y zK9i2_G-^AMfAPsQPo8Omh!7L?rG$H(v;0(k5QdtWo4Nc zo;}M&J2&7ZWfqb+$$pqtkeXt{;^x!8a?67^JpAavhl-+i%-gIjfTBVZgpIROyytsf zbN#lh^2QWX5bN!RFvQXsVo9Xzv1irshadkMOCzBy3)dBmYDpsetAti;~ck zF<>UL=BNeJ6vZ7?5j_Q(iwZ!h?%h8yz3*~U4gtnzD%A*(Fm+acV#BI0S%q8ca z%jr87ur$JZA-niFoOn`ACAUXUBJ+&vnXVQgixz;Ki4mfwn8C^N1EjOzk61Ra0u_ z%9+K%t|yj1^5t6|yvzCGm~-v8zyWo_$~f@C^AMMV*_&FUNI z&GUu(1Al(QL$ul_k?$cP(g1Nws1AwDX23FrGo`Xbq05M*48d_FQ4``1;KP8TU0}I#w9X@D zC>;=*Y>JfD7C=xkPq>JI#(9!t<;8r_JE*h@(}uCC=E5?9sM4w;y*|5+4EW3~4|Dl> zj&skL#T_}!YHtRO6;H)9s9q3zPJe5=X&+wE!U zTi`l2PHB;-j@6P2f!3&Ch+$i+J;^&Ly==#1QdrKpOy8;pt^JE!Scgy>gbDAMf$M{PLginXf*< z%;H8`Ca5bizt#-ZENq#J_BFWWe7xpKBDpR|lRLwbh|ChD$U+G+!V!=(3lszw>8BA@ z%&G8H)Y8ZihZ6DDdB6fu3mUCRI_*GC=jLeLa5{lg_2|@@(|DgdVY3=pIlRQDZ+?Jn zJ1^k;^UuRi4&$7sA?4-{EseB9rK>RYxa9mx*jlb|=e@UZq#fY>Y_0)nu0v)1w&WVs zsR=heqai>~TBNCQT98~p>KaiHD0R$0N0e!m*sc)CoMG0LaHc8n(U+_}NJsH+(lwm| zL3|f|=gzH1X$qISj^l>229NWLKmBU1f6;ar4l(JIe1WAFpT?-p5*m+Bp59=AhnGG7 z>o0$XuYRp&VWz;e0o5+Lmo?uJbw`2Qh^;IBck_IFljFF*Pij7&Ch|L35cSi2-;SN& zr@Ta_4UwY;QtAX!%8{@L&UKm7`o)3cBHvRS;*{a)GFM)B4i|1~h#?WpBa&LPXdVWL zxS8Hy{?j);^x1s}R`&VgI2<4%)J@Bv-zzS={G9W}wWnLOq*@#nNvme5t2`%GMicIN zY>77cEOe?_Qzdp@4NpJM`4LZIxa3e%f`*K!vx4YKq9i<%+|iwx<7a>1P5hmgZNpC< zMB*s#EYYLIEG_-4ofiX9BfXhf?zrP#{_(&3JwE$DLpggs6NJW1y3{w~a=^3JI54e2 zm%HQhIr5Q!(jep-SxcWl)p;gN@YbSfh9pYs1A{bS5Nis{w4GeE&C1ky@vHIFfYFL) z;;U}aKQSp#V2vDa2&hBU5t@Wcjv@y7GYdRcpUKbv+6VcwPkx26s?c^s>y6YwaiSWO zj^rGnHF}2+^X^w~;qQL$wP-1XNoJ?ykx*Kc$f@BqpZPGrx8qEMOAt_kx=tY_LOhJO zW#;-Vv$G|ka7_GEp!C?9Uk;+b&T=8KTL3%TFEffHND}J87{>|oGc)|cPyZcWb5)Oa z^>NHQqE@72ot%+UvL(o84)SSH9x_M!?+aar5>hZq-ZINc|LSQ!;k#OFZ1wnO>Z{i63q&* zd4jeXc3>rrj4krIgaR=w(r7_cp&vsRnf7RPj)^M?)gf>-K0wiycq3))DT1Vow-!h-<#c#07R*ug6qlE9loBYc zX5thP$D$<;v=x7K`w|PYTep8iPnbAkb&@!3Fy=q}!>f4pHD?o7RuR`{k}87l zkz|=d1s%_16v)8X)l~faf4YSa->59k&Eew;u9y(aK>JKPraH~l;g|^UXapr|5s|HX zoy>STdf(Th{+-A&^BXd1p26BVeIG~mGs#U4vXXR4_^IBYb=R=l7^W)R>(BmVjxGO6 zAf<#a=Xlc#XL0JmBI3lGr6*L1f+X|O^X`em?!#aF;vKhpUv;n=opM4l!{E#v8@6uS zw(*68C$VIr&Q;N(rWEmFM6Gz>iM=eXj`624Udi{q>2iv=N@yE2DX9tc`Xw3zDWnYJSsn51uX!2!iv9fV`#(o;XXn+zTqgfJ z|1^l&=YA;CMQ*aD1Z?DPq6tXmRdsVTKn9LF*2EB)(zHU%5@4Fh3B1i&FpOXp{7YkrpM{Qu%uYT zU4Fsl7tAgA*Tx|@akXV|*R1<0fRK?s#~_C@Ql(#cZ97&$O+1^gpb>0}P>b0m29^ zHW8xHI&kqYeGMF3>2unqS$^gF=Xu5H0!s(*GahTe$39IjqTW%|O7elG9}(B%zkKj3 z{O^D8D1oi?dyd)35J6aV14LS2jEh-)Sr`FA!js~BR%~$-)T-i3wj3s`+raL%;S=gd{&z&zDZ3GpVrW# zwBmBg-!+jO*>ge+Gs zXd2I^{w%-tQ|?k8|w{H}medzm6APzKyMmeVoh6D{bANDs1(_IVQZs;BDV}36Ja^@uAP% z$$$UU&Fnp#=#?8G36=!!3)(gyuE?yHE=3SnyD`)fgo32~PH-}t(lnasW?NB)G4V_9 z`v@VviXVI1%PHzT2n|jQOADAmbWfnJg4iQY;GM6yj#%HsfB5se8OC$6Z>qTGJpf!| z8FY2AeCvk}8yT2(rX^Wst60{y5EFdAhnGYcjtME@MNlW%(vlM`EQZE1JNNWzRE#v7 zaL()=e(|T@&a1B4Mzef`#5~THGv~DMfRcj8ii1cs;K^_`zxba&#b4a8o9#1a(vBxg z`mTHsbVZ_5;P#%%;`q(_o$2{a3lb`VCv&)CGa)!| zWr=k5qIA*x`f2!jeCM^!Ghn72$slptgUh_>>a*x^Fk=rz=8<&2L7zspEX>)3mtC-V z$6fa<(W^>gjJO>;cM?Kkajq!d^UfDuw{gqDx3w`w4SLXt3(?t5O*L9N?%D78AD{dh z6BbeJYFT`P8p7I9=Ltz|785872ei~p%i@NO{GacB0pD`|9I|wPSb178JSEXuqHieM z0-{4_D_W`eo!|XS{^Ngtka4k*VrB(xl`1-{DyS_YY8?RCXP;gKtRN{wO(bpdVOdF$ z6vb$Z5qZIJd115+bal?twu#C|e)Nal%uoOQ*Kqauedcu)og5%ej?ktWA(5;_>cePr z1ezsE8?kl6B3EAXA}+Y%G9KIYB)cBnM^RR!l!(!Az65c^G{sDI01r8u0%IPNa?QHb zTg+k#7-ckye#H^zx#KH$(kJlZE6+fsMN=ZQJwzP7TE&u*qDNFIL&N144;b}#a@*a9 zFbg>K8Lsd=jrI46ru8;iQ!GoCL{}1+;mA_+;E*IlZL=7sDj@_?2pwJPxaDv9#tPPY zlmc@#qwz9Z7Z&;FKlwVo^~!lz*^ju2C>5nl5DO&jy0_x+gUD)=_}B0KEPwQ=M{%=f z(+>%pW9S+N7V#-DaRn-LXn^C+dEN0$7r?cHL!PD`_VpK^Z(4MCN(>b99Q;? zZOF9G!e@Z=(GNR`!#VzEI-@2F%&PF}YtCUtmWW9*2vgakCY73>7bm)OWc=r!`0N9B z6@|-8s~tOcP}hMA&fUJ{hu(h9yC{pR$EMbcek>{)RW@pxpgrZ|cdYQKn-8PqCd4L) z#Qdms5o~9j+)Y}yN>AJ686<>hNs^Kv1*4T^E}dK9SHAx;t~+NdZM{ku2vFduXe{6< zsoGf@IgD;dtc-W?i@$#pzwu{x;%1D}gN7|c2jgl;Ga4|E0Y0^P!Ieyd3N8};7?T=F zMiKkCig_M7)aTBJmzXJiM}M=NW@`Z`Q}=*ezs$9= zlA6+nq_jYihS1-S2Z|^l9!Av8kj4;WWM>L4gZ ztGxWI1s2_2{_+8##Zj)+t%J8 z@(7wHl*KH2!rAl2uA7rnoA#8hx40097m&; z{BK>0wAZVopHyRlzugO966tE`(+s!cdYE+$HX~Z6gya?V(~$TXE`Z_)lpu-77?S7O z^LMgyei@e>(TTJgmo@6Fi*6QIqolh(_}6=G3hlU672NinXVSJ4cipw;zwq_1Iq%0q zV_T9HO(AjBEcOMNRf!}O-uL;#-23>DvaCoQn*-eRo;x<5Hz5le=0!3(=?L%z(Ix81 z0WMlh{BQ4h9WS|fk!ErbUwVky12|gBsi_!I8nj=qT=)2u_kN1^f8-`~upO0a zkVqB-O{v9U#}M+5J`j>slDjX;wAkKBS#>G_m^qvlG|iBW#S*{plkehOw)k*T$;(%M8&)?njGZZxtf3Tv){+dFFT!fvWkhL zjTw7cRF0Gyum%;OEw{78c7FAD|CA4W;wA=jJ7{A>oF@oa$_sANd~Ag)2(ewa`5z4e zfMW#=PtQCy;S=949v!AF(CyHDTnz-JF0 zT0NkCfZMV2LSk4c-ulK%UUAh6cK%()g17z>ZeS%#oUg{EB78Tm{^uTsGgMl5b<_~q|@6|cOwh30XZD$<5TKg{PH-wS;! zNz}xdWtyP;>TiC6-~Q<1#F=x6#T;c?!SnxP?!DtJ$*%g|?^=6T)d@Fs?w;9G>2vNqr)t-(wO9D9->>Oz!Yqp!i)G9;Xxbz; z%_+mD_>Epnnfc^+z+)OnL{uY$9t1~7BmV6_y@79d^$p1C1l5WrjWH*P7mAQOOr|}L zvmV-dTv`X4U^1n&Hs=wwB6y>YQ+D-E@RCb<{MiGCIdafb6-#JT%p%j?1ec(Uijzzf zF?G3(D@lQ9n~g>+Gmu#%UyBWRMZkL)$1U9U@rM|=mYc5MMQlgl97SxYQj4Y@Mu`>! zg0*0)yz0t%Vk&soBY|Pt&A$|x=|k#{S}SiBCW$C5!8uF?_Z$+3zT*u9m@l+IHXR_yH3zd&eW;eu{56*1>IW7IQ>GfGQ zW1|+@Rr$3e{^%em4#nDtPq}R_%>b}6iz-h!E3-Dj*+-h>+<1{1gv_?S+BRIgUwQeJ z1IAX7a15p{srtjMN|jvQcRjcLlOMn5Pd|PC>Lb3l9W{##2k1rTpSP4dqAVHhD50&H z4I!Z7Ik=KIdGch=5k*ezVAAGi<}&TO^|CS28Yd@6r4gMls1udKIGo_>-jn>?KfQ*R zUg2pbk!jjNHC?1Vt=%j5I;?w`AtUw-!?7J7@!$%tNhl8N_>-GCy& zAT-Q{DMj1Ih5k7g$l2>}vubOFrIMH`#2HPq%&V?j;A>z1NtWVII1!?45_`%Ej3OAENsfKs!iY4uA2k_t5VxV5ve~j~KHtmf4MIIq74X+0X4PYwN5A z>nsNBa~0A)(-3;&cX@6eXdZUfV#PTP_vXLsDB#p7Z3r}Vjl_`q?mOXi=iI5Hk<7g4 z&hA)8DB^ra@76*x9)EI$>C{t@@UbO1<`c2@*jP>J`Tp>di!Pd1X*2l0ZCe)S`*QIG z`})=fl9^d{R7=E!)Iw2k;OIKbtLr$QNk%tMLU-CZj5Eb7^KL;+W0536UzKKbk}XRG z-}w!%;qxxrij0rsuuMucIi$^S6G5<;C>MsDoCJRQ=YNNH{KY-U@B*CcW3dIBX27iE zt_x=|gK6VTnCCfM&Lm{=pNxqc^n|Z^&C8hYLu#j}CQ_{NMVZ+?Oh_W&pl~JH@4>K_ z#hA3hQb97M4iPDO2#V_(#n;DUUUB0U+OD}L>F-c72OD5_!ZR!}f9838fkfMznH3@7;3*IvgzdG*a$91}xBvP4SR z8!*c`JLwjoc8|ZWp)<$Cy6%kBk{q~xK{+=>Bx}EY zU1sbdMVuPmcWP#hA=NxS4!u0WXB+_!t?%o{o1C>Y*l9>N0$PqMP!QaHttFu^6W zQp8M9obz0G;r>BYskLp5+qQKlS6w>X^);`5@z*6AU#~cGA`|hdq`1X34K}~PyFPxL zzqxCK_XWq&huAfDl<)nvoB6U!3>no37eYvs zuBI(kP-p0*!eoptDaS`!dDB~NS-+P=vwZKhRE>XoJjNKAVa`f63w8BzF;rr0ElON@2vaK{MP7Q%9`==smY+RB4v!wSv+m${Sks&!uqHU6f42WjH)FFTVZ;`U6ipodN~Y z0hdME?C`pRHQUPD-~N~U>AUXb@nc7jvcP%eh0nW?|L5zzgqyC}hE11|kf?l#H3=yU zu>eit+H3Z)cl$@UZ>5i^$NU=5L#)b}I5J7(Q~3dB%lqUILy4A{^98X@{Oqs12hYp- zrhoE$it$lUVd@0$BAOy8X1EDDB-T^D{`Ie5)mQx1du}HzoCox?W{e+U7Ril7rxxVj z$1rJ0mI&&*_C~>DSc)`-Q1&XS)m3iTKjzKf`Bhwfd7@o8fh&7hn>9yVY*E*PSm1*o z#gL;D$4~tCzvf+kb1w_q_c3jzsQ1umki^tdl%6)U$mbX+bhg~znHnz7)Cg`$yPdIV zo_>waW&}SMT}5P?H9|Xv;x-MagX7}!=Xvc5cCobfFexq)tWR=JV*OPb z-{P9>q-h`7BAjfd{PfTLF~9ZRWf)$Bxe~NNbPYN}f+}WgToO<196=MN6B?Ij^toMF z(MX6jNrbslxbccT(2mg7@Y*L)V4|7!N)zThZ~Lte@=O2oA)Y*b0dC=1Sh$pv#g)AC z;|>4*XWz|zj~_&ON@gpUzbqaG+Hrem(VnZg#3zTjCNZXY67R&IlNJnv>BHovbTE~M&GAj(k+-|G%v9tm*vItui``4VecVS1e zfL3MDi!R(cpH)XBiOMvk6hY@O0blX>df>>4lABtDQ@ZnPF{A}@hIct#*h8vC-|-RonW|FqPk3v!%Vec-7hd` z#`NQaia@JM<2+>$G*wtyB)KWcO`di242+lz?>yGR)~eycT|F!rlqJcaNrGvK#0K9R z@Ucfu@@s$k2x)PNa^VDvtz+xj99xPb?Ag-e6ZbFj8}E3S$P!vi31whh8Y#j+dLWUq z5-!@lfW!!@G`@#wqO|ocpDHAqawdz#Sa&-#o`^4)KE0pEGUer$YzXn`sXQ7Ng5Nbp9~XI=t+s>vk3jUi3rXVa^Kjr z&SJ=E&pvOu=`p(}rR$N`XZ(1&Yjid*(wISzKJ#(rH%ON@n`IMRS49qW zOItTs44sYXBsZ<|h`aL+%yft|F@{m{=&>o|px~6yvjZqmq<>_$TKhtftNMj+0(`0{6{ z6euN;7AcJNwKeX$=MhYLh<9DTaRirJWeUy5AV}9Xo4OWI4|Etj&Yu5!>LJW%j4TNY zgNnJn>lmQZMo5|ITQbh^llMHt+9V5Iip#&_Oi>qUQiC6E<<5JanogmzPIFKxmUJo{lzNdiJcQT3RnK0p8VKjIJn{5GiOQT3!~ zI4{}IN^UPo5{d1Ifvxkezxqpf?JI6TrmMtgSVz_xVmv3)@889+81bs4%)(0>BC5)C zw9HlexA8sS_ElWAuZOK2Ly{n==pZT`FP-IMuNJm&Vq*N~xBe!7`JsE5TilCxLW}`X zMO~-oGP`v*uEF0=s1JWP-s9gJfcjaC&VHSi4-^qBMq(RIi+)s3d5y_(9?(+CE}10^ z;qZxd#?t`!E=y=7nGqvlhG>%AJGc6xaH3mxR&H_I+%{6kiS2}nD+)|8@laMAJT_s} zN=9r;_6+iW5tBAA5>t}(i8e$NI2Vb!LMba$zUASA%7MenFf14)M|3@UlQm*WjJ0IY zo=;jS*>`cpk9^-txNK>Sy7siTMB{c+#D-p2rICtMEV6Dr$~a;e*YK8**dxVxdI9?J zx%DtOD2y3yJl) zyO~Zabi4)Uk0Z8But=2@2@stF5lUB4bY=Z$nPo?$&RrlPq~{pYn<}jqm`q51!ttKR z$%MUjk`s0z-}m;1`OWu^sLHL(H&aqFOdYOKTAvt|HL+LH#uXOj6a3VdUdT6Iw}Z7% zV`)H@<|&IZ1AzaYM$yhwGy{6glqdtny}hiRYKDDg0k7d za6DkXDUnd&-8#O!i^tcN`M1CHVSeqS^UMz~X4~o-V^t=F(zhB7)9&WUT#_PLH^({> zUz3^F=ku1Yb{ZMO-_>~f%J9s?JIg!&2O7VPVRR8s_U@b*k`l3PiPI^Bki&b+75uC& zm=zq82vI!{QLj94V#0|vN752!4PucbT1LZV+-c(cJ==EmdSbe3=fbYu-0<4eVQ>tb znJWRrd61Gr$5)9>%%D^udqi~NN0N^|dnp}|)pApPq`;@Zun-pdf zGJ;#Aa7Is7sF~pW0`YLkjW4){@A+3RXQ_A?AD7XT8{>El|KuEo;`+q+%A+@8j!4y z5(%b^eV;iWDN@Ui|F=KpxBu!mlH1-y716?KRtEl9CPRR+HO8(+fLUgvP5 z2Z^bM77EIzKd6m4w$FGxn^9;7z43h{wAlDzUU_NDoB!D>xM;@?tR2z%gqH#fkyaa` z&k={T$8dvr9;vV52j6ll@B6@GY#$DoTFq$a(RMSbAoA?NwbW=jAkIg~&PTx#&vKyQiP{mc7!|Lq6R(xd8$ zx{;B9n}?9|mEM)C){&#@HO}QKV8-wTyaLWcXu0XSE7^O&Ue?A9zFHuuku*@zgNdWR zKIBVYc_o9biO_6=FvJs?ap0*qk~t2quH`=In7YET(iAKR!`6}C6)$F#H%9}Mi3*W zKsl^AeEc}KyydU>HMEx3{^@frpYhnF0L|vS_}|5N?uKUb zF@Lt7b;c)ujy=lUSq8KEwURSVULdv&?PQYin_bE!#hIpZC#8^DR5O0?vEvia3YT&^ zETX+6jLlL*+puME{@R7b#qI7Z|LIF_e8~-$eyg^1A(mD|+_IV5f=S;OJ&sRC{N4v1 z;n0ew@FiJ7GvxN9nXop3NdgTZ0ksUN5aw~w&;-_ZoM>A<_V_Zd*t3^C+YO1!;430S zDl0IijC!k-UZ95OUwr|kU*|&~`262orG*Xa?F{W)N@bk=v z0A9z0duGhJE=YSVqeLh8f6mtc&6tyRvkQ4_>?73=N z@xBk<_eZ*W>(Y`410b2D3DBC-cap=})p1~Xts&vFhh&zkNirkx*<20I;eeTStgoG? zqq*J14d!`rCGeJC|5Khk5pmTVp>1=h7jg(*`GOc?)(~h$eEl1~n1B8iUqC%Nk^6ML z5o3!cC~&xxI}?&5%t11J8jtDBojjjI7Ic!lz#WXnfqU;eK(c-=+@vg-Nl}3J1QMYh z@$wg5&QJgFzv0!dxQYw5dG>EruDx)P@A}r)^IhNm)eNfK@b8_=nyHq;FQv8>l?T{yK-^_?xmZsA(jibc-Hnxqaqpr`fGI?IHjxu6nracI_8DRhM_u#_mP z0qeE$pMUXx@!pRzc5?{Y{)Ku8g6TC;5+e8)F_DKEW#AILBvmh}q>zP^9jdi1&dxE>)$O>ZJ_jd)TocNARR*!Jg6+8IuZ~ICv+SVsd)=4Rn zG<)jSCSn~>94cUg?HoAi`0=0lAAIyvk5CQfGe#`U3QdU=A{`4Mh4-Ws`OMa~&-A8d zWAiy~URQG3tEa!8$t2jgp0llsPMFHh_}q@5eOVzFE}<`A{IluOi36uU1bfRyODA!yW9P`8%8uTld-N) z#^PvtOT2ScX@BEEe(GDVVN1Wp+I8@KG**=39!Nl3B8*#>%9=NS)k|0%DZldPAEnnD z&@*Me_7q`A0>-_D#x*2O6zvu?Dl_5l*;LXUWezPjrX155tigyj!06Ma34>}IAN%m* zJo;MUqAd>Mh^8nBKwD_8FSRY@_y9Lu*5}3__&g3BTVYbyY}-Cy%R)hz9w)WA@mm@t zx{BHx{V>8Q;0iwWfyX&^yieL{G(J(qmNF$s#z+bbMQL&Y3!&HM@ILrR?Z+fZNZf@0 zNppW=5hL@>l!-4HxjB@GbiKQ?g(OyY3FjJ{|MEPXy9-!CWHN$`D~LO&Q~vY7ZmxGFGV}$N-}t z>4-A5^jo8Go<<826~Q&cqJc1^NRBF;B&j2oecI)deBq_L`KfQboZY<@Se;_7>_Fd5 z_5ju6>H$VXwgo=4vYQ|N`FC>fJ!5)<0mvGW0%O3iHjG3l0zT#WNjm8^meb)qVl(e) zGe|ibr(}S5WR|*+fJl=`BCNH~p?S14dBE0{2#c3C=(zYprB+;!hcwZnHn_{5}KK0ZE=^VE;76^{D_;o0$PO zNF>N?M42@`dxcgQYK$6V;0>&$v~rN7vYtq zqc_-2OGR5iTSgihoO&cc!4&V>j+VQFE^Pdq9rVn}?w$*P;5#9ZCQhk(aOC(wwr&~n z{2Q;s>J^+#P)&IurUK|8eoP2c%wWs>0$Z0BC`p9r1WN(BhHpc$1dhx>s8z*7Cl>iH zzxn|Vu6TOAmV$_NYI`PzSdsf|)w-La^BOeYT$*~g*h4kr)3k$Ym_T5jppJ8C{GJ)+ zhzQ;*YinzK_|5~IfANLvyI>oxnc&2Tp~>`K3MuwbRbr@FoS)+bS6|4X1NZa56C=d! z#u*f9gw$eEktjOvEN5(yK+={dy$mj=$f*>Gw0=yXM%h4>0)@0RZji;Arcd(4&x3#e zjbFm~3k4~Rs3qWwd0KM>r^M9aiv(#6ncvDMk8R^efA+Vy^Uenu^anrzS`wn6N>1I( z{LT$M>)9GPeZJ22{IvPRPz@jrA`vqWU8Ix^UxyXcn6H-^YT8{}>!3=x(Ll|058XI6 z(?Ha$f|&JeI?u5yoN4m7RN!MK%~k6>?ru$NG%&y^9q-K{^4ad?f55`=nZ+LI{+%r6 z>T}^(H*RfpIcf+|S=3d&=!F-trCb9%$qEtlh*=sySn&M?9=i9?@94t9oYHv$WJk`= zl-_X4$>lZbrtX@OJ2&-dCgn^3DC8=SZ)=vql-88da2LPx;e-6|cl>2;F-y6d#Ck?j zAqFU&N8^MPkFec8&X0WC>v`=Lem={qHI47F?gFEtA$f=)(sz9{jWAp9Zv0cB!GZ)A#|1C92^T9$w$g554sry#G_5rdO3%Go>iA znxFYZvI3D35r?cF;r!uozW0r<;KnPiWV9+c^CUBZXilA&&it0{%Nx)Mfr3^l8tH8m zeUcx6)l{v}w<(2Kv5L}OmlA~Wx26J%GOeGV>%yG=en0-5wQV=!$)Z#jRb z67XG)D`?8NOUokzI!-W6PLk;2-X*V9h0@mVYm!yqc^24F3omf$Ns z{73`-GG203Ahqk6qs;wkM zv}Pa$bi}0D=xsvppLWdBQ5$87Y&0OYPSOEMdmK5LIKDRFMK@l`LIsQg`XmkD8iCE5<~X^3kBl1`#>1h=$>-+%u)KlLk*KvfX^GCf(uqNk}&qG^D} zMZ8TwBXwc)tYwh0wn(wFLLOy2l&+Cfb@sp zW8O!Tqu&aeO2)+yRB$Hv=%~_!N>0%DNYFk$&EcZre9hQxq1FXhew44c>&QPtD;|4~g@b4YJ23jwGJe3Bk3*Vx1_S#DJu^H#xIeJDla> zici__b>=gN;s$i`0Ft^w8QFu)5kc;S7Z>sx)__RZSof!&iJblUnd$Lo8~%Ql<1;#B z&g4n7?)gSB$8xPeUG4%AL&)qst{w4;=kMo&t&tG&OghIhgp|bQ3t#EV@$pC94}I_J z|FzR}rC_Td$56Lm$byqdBL(ld{R!?oP$T6$&RS*|oXu(5XKDn!dMp~j0Z#GiK?^>8 z*FEfBOk8@|<4i7YD})fnXyJ(lB8n5AeP_$Y)>IM6 z#1ST7h0vrH>kZI_?Y#G6kMI+}@W-syyYN*)ikjFIxrm?>TQSGt1Hs+Ie-^!JjT*L)?cOgwnYl&0{ie|`; zhOAf=BE71|mDgOuLkCxR;)#QZ?`1DGaf-OLyIH04mz03e5M34`@~NOBO$Q1lqvO2d z+O2&5H@$+rbAed5#FW{uOK~~7;fgFrV2)y8EB8OK#+%>rr`-9_3Ul*|SV(x^g}p9& zr@nB+)Zsoi=35_Es>8YYlzAkAacbHpBUDrD2HW7!OO4pp~;mvE= zHaxjgt*0A>$C+o@d3x4)hZ~s1F4UDx=Xln0JIhOd-v;n(Lsd84-yD+XdC$5!2HE-0 zJXhMTanqGMc;0yhZJV{ORcy_$RTCCGML8a|54azB(;L1wNjy)Cm1J^ERJR*4bP}0F z<(;=3;@-mn(H=!tmq{}Z@abaV%_j#Ljv)mj4X7ByX(Sdsj=MSj=DvmtwhXv#|H&*x znM#72u>==XwuB1>ld#Ov!k90*dJ7L9TI7xgmKh8e$w2OgLCU$n*3B|GGpttkQ%?Oj z4KXh;b%IjRvjNT;)Dl65Xt|9~+&SX#k;7bn-3}Ia*2G%DdSIStMkv-vK9QU!**umM zaf#^HXmy#eFi%_V;ZNRsfdBVx@8!_)0;Tjo$F#Caa0^8B6w#=Xq6Se)f*R&W1mDmU z0pBjs4~`-kE_qxmP>C2qlpdy2a1B+OdQU9p{@etS z25g?J>0BcnsRe1F^DqMv=fH}Mf1B1sHzm4;;2=^G=>wUFBYUbfj+Vl@xf3I)=CqOb zByk-)%#lP%q96_v$1D+X+M?g=7o25$t_|R`f!xe;fSU<_XDR!9M;nfKiWou2p0q1y z+A+_+XbUg6bdI(O8J%QtB*JlUTbwKV^|U$Ue)!+L{@+^+i;J; zYqVoTi4s8!O@T@s)Q3wF%UbJOK}IrhM(x$oFCJjp-0tl{6k@s;eG7n*TRazjuhnUJ(XL(cys1=+Hjk3J^+z<>EOKJ~zu z-durFQ>21`AYOs2)1sQ`t(>0P95X~HWBd@;<;*_whMnESXItMUk>H!`rJKh|i!;NU zr;q|-q|hne4A%;!)_AFj9fnZEA*qB^AgMsb;naYD(|~G1HAfwdc#;-GJSHAb4>gaE zIfQmbrz?}|zp9p*#YKoQr$uHU|EHqcdbS4eY@hYn;tJ0Nlow{u7ng}dET>#5S7ObW z%g$fo^Paaz45@1?wJE~UglrLH=+ISn%bVZu6STo6vt_}rft{yHsRFAt{NAmHII;@9 zC~>4bp>oQhe&$2SnjtMNc?{2_^z>4o3=L8ZI9Yo>^~r~4?3h&Ui!OI}y zx^zmEM4cM8_FG>3{A+piWWhc69tKyTE@svVb)-aQbLkeBbs_5pvPw3EJP56iNK%Se zc0~@wbb@&ZB=GY*{=}FMfB54(_}C}#u4U)kR{FypzL&GB&M8_9Kr3QW9yxG?-~QA0 z@wVUi3;z6rPcUW=zP}X%Dpoq|oD!)}lzvJZmC4o>m>SSf#;v%x$W+Ie4~Pp$%!z#K zTD?jEq9LXbMObrW8aP%@qFXJOsgMkBKHm<7Hm< zyz}|dZ+S8M2WzBy(y0knh{bHArOaeCp+yIM?mlpmH^1eNx&0wyxVRV95o!?)j^r}A z!)bZG4d*`_!Bf2lok2VY`NX+z;!KSnT!W-4=L2avY1Iyvq6SRx=BQkS*q9h=%oXU2 zYcnY%p>125>4X$(LYNTRF%}zAXldGp+Kdna(XzgD3gE@zt*eS!7KXy*u-QnYkfmUY zqCh0mdx30(m}eW$)&QQXLjKu|bN&6y0A>uV5=dCI3_S`p7oWex7hJuC(6+g@uBib# zYPhY6%sF*YZ0+q9*PCR=LocQ{;-NloK0#$#AOGYAQ<+5kdp0#nf zk}G7E<`4uYiN+bN8avlB3-U6`?nU8yzxn07?vg{at0(aaDQ*MlV~mkwvYHl zG%7#!Yk$elz3Wlxw3lJe(~m2p&PjH@q75wx>vp|_5;h%~AQ?xFbd*PGmMgHSBH?L7 ziA6+RLu}Tmris23?A|lSB^T~z|M~k^TJ)4f%X+=ak>ktU|KKtYJTT?NieOsN8&pIY zgBdCjw8qTkFvy@t5z#S`Dr4e&t|Yp)q90fB(r|LHMAIm4qDXs!suI@x4ys~Gl}^$R z4v~_RV5bwZiO z&?>4WjjdqXP%U^KKWO~WFWNH2Pf)Ee#;wz^7 z$8UKR=k+5tu4&^oyiQS>V9XJn(xwJwMAa8Qc5uk|z2&dD>%k#g=Eg`|Wh}e!8c|sW z>t~SrtRQfjadTj2w!Niu8o+Z8BXuFGlM&t$Y86NeQWG$uWOiwz$x#J8oFA|jg)p5^ zrU~=fuzfyp{?3B^yGr&fm26!aGFSDeii#MO@u*>KeT8GoV;(#<;jxnqht?y@6Gu#a zymu5{@haVh5fBn4Jz|U<<$QJwoO)s>)9|?%n~N8lUwcL~xH-S~45nW&oJ68|k~(S) zNQ9nwLY(j=pLYd6_l^6q0knYAxN2~x#p+_`WVc8<>9N#t%{e7Q;c{T2O5H}rZH5eN zR9(zSb}F;>?1lp?XQT_n{E@f>t%5j5Aq59doaE>K$Ny%}*I&g=m+dDkA4aM`itDHp z#F+W{q9~GDL_FXAZC`~feTZND^$#NLJX*}k=0e*{aK6kru@t-Xj?89p14jV2KuEvj zELWYA%qagoDw&eYNe=&0G(_gm$`K!@SxckVHj2b1m z9?3gGYe+poqi?BTBngx57({aAS8_WY(NVb`)+8p!o?yOeId9h@ z*IvG#7hJucD=yj2{%w60dPe246K64HT(k<9QbtHxaiUHfTCaKJ$SU_d@C0|=_b3lN z@+3!3j%X;+Y91>FBr}SX5b5aTI{DF%(&_Z`pT&6U6v5xouv37ZuF!$AL=yrlE9=CN zaGg9aqF%Coha`*mLgzdhu@p^RAwVIP99@_(Z6c#q^8(snk?6!>o;8ffy8hfgrA>(v zSyQEqMlTt48jeAz(pZQ!rYV;dju*9 zD-k!r$%vT1>yVI^m|DbnhgSW(c*N5nw349->XbxHOjCO41XY#rO-&(2twW}g0kC2o z7l(L%l#ChNO>iTUt0`hHi?^FGDF%#^anU^do3Ff?FL}{`x#0rSw&8sboaASJ^V2-= zSV<})QY5MrJ5I)Y(?4aNM^0U%B%a#Okx;~pK|D$nVO(-y6{ybd*3wmX}_2F|WUIzzbinhYK!PpeT+355i;&Q-`HtZpYNzT^0Nk?*v(OIE5XB zuw%({?XDhQcFhjP?RM@va3#0ieU!hs^ANW_u*Sir!uJF#ViIXe z2bSom+OBljF)&xIGV=lt#1BvD02p)HOPUIa~^vMo?ew9;_Hl^1Zu{#|_F?t`qZ ztkA1^G;N^py_^lxnE`}ZCQ~x5hvY^W9^zK8UcLZ2$v2J_*?=bj8RH};uqBLfgiNJn z4TS_sn@~0r`WC37QlyfQN~T&e_9d-WfWw>MtVLu>RHc=Q;4H&AVu82Vg)U%WjeWgk ze&PFG#%sRtYWf-|${}Sr;Ht~d49s=tJ`Yn^j*3Qa#-Sf82qn_VNJ zpk*PRAd>*(&KhS*nF?wtshu)cj5&Pd2)8{PxaQK!*|TRmbqI*nlrA7eLZ!yV2A^tZ zrc~!WpNEeP`Q&X+Q1yhQ6Qp&Z#-tk2DyX05fyc)@@Hkw1!^>GHmoZyI9W;1a*8>s! za2p?gVv+Ct=|AJX$Jbcu2YO*l(RiFWoEan~xjwb_(3F8B8@b=^%4bpeQ<9!0sVhq5 z)B|1X;4?LV`Gs5wrpxk5>Z8e1m{LV!P@iOOd_OO{Eb`s|>;}H&E3V?jH|%8VVhL@a z83|$BN753)B4$HWJkFIk^SD^zQjfOm(@H^fJ=&x+lNQ=U9A_L2%lDcQhgpK%yblBFEQj?!EgCF1}!aOLp$UH36(f zXaSE>;FFT1A!tp}jJWv1OW1qE7x2&n_w)GUPtfbnW66O!lBEtzIm1)U!lMLQgO!lx zI{a1woq}bcs?afnXp^yQ7IEF*n|2%`~*kSCJq5j<4p6UrJcs}>N_O$r4|3kV?W|9gzy0KKqLlQt0d*Vg zLmLb8>;=q?C(Z(cv`)}I35TQ3pxoqXeL)cLtW)+EdEj8n;rl<%byw_X$M!{t6C8mi zMVyna)4`Blk3V_;eZ1}0|D4g-dNig~)dKfDe280q{*U?Ofkbb(1GhZLqU)|;+}q2+Cl9i|zK-`f8dA*=l391fneeu!GibKy+2-bm=VS<3J zXn08!2CYHAP`>)5yQ#{8WD#{HW~@5#10(j`pfGpK_kPtaZAfN9U1pDD-mF1-bKH5L z;Jvq<#1)z6PE+=tOwUHU^K|DXB#8u9XAi8qIc9K8qlDxrQg&li3?ZOJ$-#P#kKcWO zJv+8@+4%!vs58#VDN$OSTSYBUCMBdr+Ir0McCGO88_ws%$rV2N&mHL;f@wso#HSub$g-cV^CBYJVY70hztMqO#bJ~L@dW3HDnyBh zrtTP1X1hg*slZuoT`E&UMe1003eW;2(bNr2O8(zBUd2~`{t{_4#<@%y;LPKIq$_AS zWGyW4-j95Um;gWI6n$j3P|sQT$js9Um^f-3WM`iM;z_EVx>k#~ND7J~s_iUoQDSJA7Cn+ERji@EjX!z+5#IFQKg@cg^j#p-Q0M^P8&SKKXhX`jPnDqH zF>odKJ~ZIT2Is z3MR~WD^ESTre>LWeVf3LQSQLy;2wx+JP)cB9QH)@Sh849VI^ z)1Ud;86z9uEQ68Vsa#a7t)M>`@?|gC%iLUx*_g`uIP>dCVn12xd0&{jpO8Z@Q75Gak769wuXO{UJf~Y-$P=maD;8MJ;yuuzVAU zW#fn>l18FSMCruPXFFz&q1ixaZiHI76C)?!Pxm}#OwUE<4ve?I^8%Wgu($B{0XZX&1?u!JT<%0(VLc9M7f%@ee736U1xX{tQkUiwT!yp#;@ zGowh1k*catbsT*13GTV?KCXHG4eZ=LB#hTklw#om{^))8^J8!S6OP*)<~+HZ#S_|) zHyBsoWeC<1Q;kJ1bGQ;7f9w$sA3n-8H|%9``z73c-wHqcmfz=*14rrgdzdLEF8?%_ zDbwZj`zHVIDN1%{c{#tNs3}gm_Cxw)0s=Dsq$S#-A-U9nY%Mzo+7-28?ns!9j&SkL zn(z9$SMbI+ypmn>1?~C@(yNFm1K1_y&K@rwCrTDv$@y?6pw>ccQDWBK4lSxNi;g)X z5i0L4=wY)Pp#t0&S2PV*W|O&=sD{+ z*G-r#5-s9;OB`SGeB{1EY$+#Pf5{S(Y7nSp5wu5ZC52yyFs1N|ShLE|pWtOLx{OP9 zUdCPbk9crxown!ctHU=F=2Jr~O2#RIZve&F0F5P@9Hvu}uX87c6M|;UQFbb3VmbLC zn;)dk!#x#L$i{yGB~itqBR^24&UfrB0eqq*VC_2J^$joK|9Zo2s7Hk4@H3)UpE5t9 zMQDd;f06fm?9X`LM@y3Jrtm8r=ij+=J5Lv?oK`LG{>ChAAoSa|#i>yC``mHVbN77@ z^Ldvqv2$xlp`Jf|-y^*FZGX$7aXV?SfR+U*O!0UmL2C_#^eE#TwXV}z4PgteG#Yn^ zsC#+%ktru8w-d^J{PbJ@n!6r4%-q5vF+oV6uFP|)oyRqM*r}6{XK4TdT+&Qj;U<`P zV%!dry}@26i9WD0SVzbPu?k8uW#WZFGe_B$thDgr7hS{;fBOr0^|d_)lOv>7358Ny zAeA1)Q^o-fpIZu3ZpG9j&<32fsE%>I#k+{d<&fTu3EB{(CAvUz+2Fb0@r|Rff`Sq? zq4q6JkvCWkBj(!2xNNWIRX4wY;r9JJb_fn1Z3q+;MMa{fw3fa#OteoFM=wqBQE{mx zxSFT|lSq{k{b;y2Pb-d5uSHF8XP?%m9_KKCGiUN?ub_mCN>Rg#9=F|plK0&`rYN(;(;2JBbFuEvX&B&X ztjs7qr>sTC2S0t7k=wyd7w^NRm~l)treB17Qx*lm))7|`+a4BAaM{&6cVJkA2W8G!ag)#}67D$w|Dl~=B`YeDWR#JqLDmcohxK5DW#FK+!*Cot#)6F(B zeVI)-vv=f2t3q_1q}g>ZEaChF(FSvhpG;V)kMQrl;by+}^)DdU6o~~TJ)*3m9@-@M zRH9LF3oQ>GPW+c&eS$|;Cuq-D2$^*zf5&HDt)~L?o-&NHb2*jojWOYz-zb7Pr&v<@ zuFs)E$N1z}jQE;1Y}(u>Ejc0yas5hb%r`&J=&0)|hSTf8lo zX(b@CfHNUE4Aq%`IPd9hc3wFluO0g}FZB zOAsi+1S7F7B~e?v)~MEqGbrZ>gRLyf5(k<&9$%f~@s$Bbr&}1soussfUbTy=*T-p# zG9lRrwHomb)DvSvQls#oF@v%NPoN}(nqk@Sf(r(G@l|tdiN|>K(fe4%F)4GbgA~S;M{G6;AW9sml zc-;%PuxrPF6q{@SMT1H5$;|sA^A_a~J8eO9d9HMN`1DMuus)X~hl`mO3(F(}uBe!X zg8%VHA7bv8U%;TOY1&B_T4uStq`ACb6eTf5rbi#)^7FRxL;v>cx$?^Uc-tR* zlE;@%paVx?M&&$pbR83O+{6=?tD!k(nP1Cn#WMqOHoiUE^z$=aW$P-lSy3;u9SeDW zoAHPRpZM-Kel=h7g%?6PmRo!hxd2lLZ45XWKuBm&vRXI1^;dt7dmidj&Q~}Mq$F7& z`#H}1Ij@M!K(wAv8E&cKN)V21zlVcD|`q|2}TN?*I=xae~K=tx(rsNl{7$TC%m@XWyMm6tDZ z!QLen7lvq3(j<}EX(k779S@}xv?1b)5)(%Z4Y6Lu+Zy|~_xR2?{$nn=@kV~;_ddj3 z2iFK%5bzl<*ri8jWUhI3I$e(3Ih`FO=wwx&lmGWQ89CQ|%0o-ZP|>q*bjk1tsEVXy z1%?+@V$3k$$P71iN>OTtqvNc8#piY;pr?j!S6S8ye95pm3EIQ@`}t3|PPldSAiwzK zj``hNY3eDJ7|CF@SfH0$Xpf_5FQp}@Pcko`=3Bq2;>PQ*;{W^Yhk4JfCs>ciVNl{+ zLTK>TBBArxtWU(zI1s6s)yneaE)bIqJq)4Khv*DnnspSM=EECBTtHJnX%m9B)D<-C zUe-?5T)Q*zrhjn_U;2_QFgZkO46g$eLuJCcZ%|6+MkQ2hBv|E_1RPGVNc^JtvP)G>QIT3Eu|P8W5#v40iz8a(SH4l!43L&5?w@>D)h^$#<3JNft{j`w`z0Pnl~2_AUt zICU%#-HqdXT# zn3Ys%0-;8v#?wRC!bCl-8)0%6uYKVbUbg=Q{Oa$2nqU3!102ys25u`@q{j#!dJyNC zRAWpWDJ`HGS)3z#%eoaktzpPMq{Y1d&Rjqxj_Md+#`?n5hQUr7ORN#`}aF7zN2(&t(l^(Gz*|}o}uXx!_?BCI2 z`RJ!Pc<4#$G)GkpG4oiQ6+&{mkTbnNI@e1 z^hBJs1oI#RT$-af@d&TDZom(J=a=xx7hc44>;Mm%|lm-jRsOO?q9$!{O*VO z?|<}RifWNc9g;#OBP|>v#wp;&=|j(1$$tvs1hdonMl+@|<7m|>K_P{PK9SOCX8*Cy zkJdadmW;wOdv(&B1A`8Vkpj4gSHt2cvu7uTp2N}v7b9p7I&q@B&=XkfdV4qBF`G7= zr(CD!$gF3Unl`66q@u;=w%U{(jAXf9FRsTlfv!Hx>t3{tAN-EjaP__g(sYFQIf7;G zJ|~*{%&kJqVPQ&B8nU#5M~}?&>+iUoxBvEE^XIqT$I)>>`#oGwC@OG8#A)arElwgm zH7X}mr6*e8@X=#@;;x7I;BEJE*8|5fS!DZ;OPK4=;nM_7WAFjd0*eDQ8M#ewl)EQ% zNSr2y{W)HE^YhuceZZ5CJjBrxhv-!$V!g~x=!DeGsiVtR;)zm|B$1@*u)Q0L=CnyZ zvq*Jj*n74x_^I;L8GOANdXz)pfZ{ds`kS_~XXlU*YMd2a&(l12@QnTV^^n#wtf##qC%l9X?G)qeiNx7@^ebH|92Wz^46hegyC6h5bN zoRs*cM+y^M)i53y?|AGtdM7sjq=~7P7{?= zWx+H5L(X{R8Q=+L*L#)IxuiK0Ya|Kc6&*)=4@%nT$VW|B>{E1bDozVFL#NzroA z-vra7NFuqC+aF%zgZFj@5W}TSRp6&Qtk2y@q6DcZEMM@(RT&qhCD9yZS+Yf^9IuT( zxp&HFB3ygXeg;yLXlWQ@I>r}e4n1Ro*M`Ot0YlS>Z8GE~*Ivr2UpUW}-V+>o@&OKy zPO$C1ADwQR$9_vMq+9^F7Wj&Ei^|+_y1I+JmuB&?{_Wjgn3l#5c*qZLn3I+osq z;ZTIajK-HteU;}kC90@2PH3Isl0%vT#~f|HU}O!wR3aW~-(vV~!5gYGW7qBen!C)? zCI%c~CiifT^F8bNNT>DXfO&|*z`Unry4q5C|^`Q(ShSt)rJw-jlr8&d~ zV13jos!-xn$+++&lwJx9Lqk7KC{u&_M5+qL#gO|?B>v*|C%E5i?^P~oLk4G zgG3nRO8!mHjK3*A+c*~$roI=pP`)O-iHowGR)J+7JRi%YDakUSW(gn zq%^=sp^PWGxb*aO_l!^n=X~dBM)!v%bcANP3hX5Ex)*I_&(1kgtkDdEF(io@;>*(9 zE#LQLw}hAw(I_S+5*-prTJiBm*7?9cumQ|DN0*hYgpN*Junu%AW>}b@Wsk8{JbcgH zJo(TAT()l)ThH5#(b9xQ&NIjcYp+AZpJMSfgJcv=@oJ>DrL8BVdcywQi~N%>yP5C) z_Alo}S1eFA>sZquZa_!@S9B^B34B>%t$;Y(OFzt9Bb77>O zPT8^4<4te;M||}cTtcd!#3x0xN3^aZL{j!V$^s`)^!haAJnz5bQGVjB@8qL*O^E#^ zLg9d{r(LNMLxNZ$QgoQpjx|vdu8Syx&sWx4+Gz7LIyE_U#026>)RjDT@Hij%(Cv(m z9bwn*MYiu4plY-+k))5smXcgV${}R|DUsSSJ*QlM{dH{Le=(oD_aRo+rc_1e)d@t) zUUs2X=00TFp(8%~*1BgrHW@&n!vS^%@C`R_XV1=I_H0vuAOT5|Qc_g8?WLkKX1rRozSe*gkmdAF~pdl+JLk)7VtWUTOU&R9^ySe z^3EstPrq>wq1wq(TxGspqt*cvS26WM6^$~b4ir4o0e<>0V$;7kX9%pPxZ`G-mqc#u zGb*H$0LbA-qOckzlEgFB0l|4p!C6aT0Z&P^Arqaa)Z>(_fz1F^X)+oGyTfqA) zdTx^SU2^hUF%?vDdsygl*`h9cekvV4{i)CCT)EJcpjD+TJieY#sZfgJ$Ru*<`E&f- zx9;ZEHC=kfXc4U$m18P}VqI}nj}?Zz^)Dai$KG~;Cyrl&Uu;k&^fRp~ z(Ts+QmI2ZCh?*(WR96uhBRa{lnQHV?7wW0fswb!*8YyB+pO%5ulvaa6aE`Vd^1k~U z-usCa=8Gwpoj;^k2~~ZPQW`K}Dg#E=4AAO7m?_Y$$ zPU4_}xK3pgs@CDUzV*~G%{=op_uR+k93Wk4Ze{?zCf;!Kc6RR^lH!H|93x4RnfGO3 zV#nWZ^`tQoH+4xO8s?COST6A2-}fXx_WOtF4;3R(hDtDrHdn?+t0}vXt5G-Xtvq?^{s~5B_ z!3im~m{(k{0G(rXBs}!^aqhbB0C(Pdfcqai$`eOUusUjKV?l^rUQ}md@DZnpQX>mJ zWB=|&uDQG^g8-W5n{NaGW{_?oSJ&$oRk*X?eIqZ7IN zf+4L1lFM9@v#t~i;Hs@WI_mS*-+V8B@V5^ViyaibA=)l?;k*!{fO?!3q?B34XO#C@ z)XQyXgn&>a&D=F=bazB|^OHM+(ER-(#10uziZL0F@HXa^H=fToz5Zp~eDNH`_uk36+lmb45rnp$(5re#f~IXh1A?`Y zKi-rN{G1rUnYT^GV>$$%lp06Bi2UkzT*38MZKIj2Qp5p*u9=21rdU;Tz0?Ut2!^Gc z=9SM(_WXkn!*`g()_IgE&Zb0HW_CF9ICX$gSVI#Fn)&@u?c(>|_Zatn{P*~dueypa ze#H*79MCqS3`@{l?X8PUE8o|Er3xo=G;K?a4XRJF;NZq995-Hh5!1=@I5u(|IJU;2 zBWoNu)^OtFI_skm$s(f4U|6s;U$A@o92cE8&%T{~w#_^GK49ShvX*--gJ>pH&`i)8 zlE;dQCbSgZ5t~3YH_wUX6a3hJ{SE%`)@7`B5!vQX(2njuYR0K$uDq-zoN_<^=k_X` zg*Iu1A@vE`l=G_2g^s#DR|8cHWtu-jgiJu{I;nz)Xu$BK1TF=X3P&GHBPNq|_HEzI z-it2ej{EK-$pGhOIwu`nRhKI*3P&5JSPB_iI7_6PJo~NMIofCpT_g}_{E#r6a^2+@ z@gx8809s9(TY8nacUC;=sthu40h3qbencMwRT&O4ZG)Y zH7W_A3@A-@@f<~^%$I_<2vP8^AZqTLRpXqD!k_D50zvW#&Vnw(6Br^%y4+79bQ5Bh za7?M16@-#R)rmT@$MQPUf`6LQX=|KIg(*OkIAh%mUjQXS2a( zFI~+w{mC88n|wlEN3H)X>*MjJ2gLJ?+@DXV~M%{&~suFSweYe(N#b z{*iTt^Fx-xGHvS7x*n~znZl}Su;_?Whc!ZB5#l7fGm?#^c5RF^R-jp~YBQ5@0%FQl z_u3*(Jv6W_p5R~pqwBeF=agnLf=VbDAvU6APptJmzI#K59Ediv4PB=YT zR-Ws~pP90q7#g;fk77l|V=Iv#``t(R;HMMc^>sIM<=zQQ4&e!eP+>Og$RctcQq)6} zWncfvYxc2qcg4@V?KU1h+)^$q65|Bt$CM6IG;sFGMjRj|N94b5TBx&Btjt$%d3jv{2Ykmlb^bq58e6^Qi|-{ zzn{g$C6JWac2go*!g)h1Q-r53!2c;xkRi60y?*qWfu8g@4TJ={QvzP4;^ag&+Q`34AL1>a0+Dd zbiKWsjh$?Ioc?llF0v+yO4g+{H3E(l74Ir&Q^tXn%5=KUfGOYc^{?Q&{>7^~QAh6j z^u4sHqSxz_Vg#!ZQ9^QE@d9EOri*3bfM`ZpLMsKKf$h~Y-~Vl2!E0Z-lQ<1rf5S~2 zUaxuJzPsu7hC~XIIil-mtjO%YUObWvLdft4m(wR_hG8=Z51ZbDd<&{UAxcaM=SwE7 zv3D`?#xH*%+xj(`E2n~#J9&~05F%cTBtlaz@dtl#2S4-6@8rOGh3{?c)=<`J&T4U< znJS%-x1u}~d1Kd&S7EapW~S^9or~|GYx&DXfcztUsQ)hlqAhSB;3s2`EYk zJuNYJ%fI{5TVi7-xDg^#g(U?j3m!h)@Scw!haxAhEk!&tsk+a+cm7WqVoEWc!>izY zq)7?s@1UtJ;o+4&@A=pR+2Kf#Z`<3U!^g=o2zGL9&%$pu8o7|rBrlD3_odZLVxet;r^vz+rwDw*s+ z>r65$Q&egU#cTnK+lZR-MvBL52x*>~7?vV9^JsLGwuVU%sg-pXIy zetv<=QFWg2HgY;zQ{f@8`=m!LxkJll~sl2iIbvhgYmE8}ZEe?Ln_NsPII zFQUgt+9S1nt-QHg-t^jAQk_h-SW9MO#I^&7cRYEb=3O5_jWs5P>FoW0d!{OEVx%&T5>35=&mEWsB01|xYG|BCq(%IR+*y?AUo(NNOi;@ZC{rP5AiCU7K4afZR2V%Z<@@_iNXRw1|Tj2;nk5SP}&T0d)6M{%+zHAn0t~tuQ>&lhVWId+Q-lp9c(8U zB8M= zD9#C%reG6>RiD)MkhBf6tvqtzC?CAz4nF#+PY_m?*|}{yOWU{M(*#K^PP5A%H;kAd zmhw(ao%0ytaxqcJ#Rv(Y)-{g{$&`r0%#*AjxhYN}-aP92j7P>FzVA+c@|WMkAN}nC zj@5G%!$pd&moX(p9l+TQ;M4Q@ovo<#GzL&2B9W=9y1Zt>CBairsxS>TQ-tsNhF9{< zuepLWK8&hh=I|lWlZKZ*?`BpF{`S)k(xz>w@8PI(OP3kG?&TIoX&Bp-sDb@+1ODB= z{v!VIODdSwNNZT*yF4CvTVY;J7e?S^i)N2?yE3b6qO#9dRTn|S0Cl&FS>-%PjJm~G>veQ(BU2)e`1v%`)|L? zyKY}0_VE#qMwodTS+koq0UA?JjnW-2W+%bbH`h?bCW4zLNSx0PwJl?NU@#=UoZj7xTV z_U+q_(}dMEQW)k0Ylol}t>sWy zl~u8{hRDu@bE3fn>oJ&K}2 zs|k~E4A=8SS>oXF9)J0XHQssaA?|;&Mu+E7&23}Qo5xjIpQE&f(x#}@7=fgTRsvCK zj20}C(@~0RH5-hWMOsQCrAw4WL#bP7Wj7BmxBSlEKE{vy+9&vp_nzd?>X5Q3Sm@Om z6BkywCY|bm4F-&rfr(i-|C{yo2TFM{{Le@&g;Gh^wa)6)wx;=dD z>#id-Q@kud-BM`6Y#ViS#KDu?^t>JHTC4f^p+`uurie!4p>~NL$}}YE!H}o}nrYx) zzxE>j`KzzOR#s`E&$LFx8e$V1iI6H%DjAlMS6#hCI{XChf3(5%OT12rrXu_)J=XEmq)_Ag6`y$W1dp#q_FZ@_{oyXA)ozx< z0`I)5aW4<@#@#V<7KPA}Q9xge555sDec4}CvDBK7yHD&D6P!Y>5w38lvQC@z< z92d+@xbv>NIL0m@&|?Bpkz@cQT6K(yl0iGANG(&>C)3(L+gg3MmaWTmZWJdRDNea` zSHwaiG)0XrkA<@5<2mvUEAE=J5YHO)HI zq)om|h6!bAsjlBy4Vlr1`6(Gu={iJ4M{XerBMUpJl2b7VB4ze^kuJs2BASmQ8M(h* zPf@>>)f}U`;r81<%5>Ur)g>3PurwgFtN0EKELxJBQnwR&2!t99fh(@QjveRiRe5L^M*^lpRIhMcMAsEOQ zZA*L{qIgUaZBf${V_G7LQY{P^#s)cg90_BrSfG^xS-O}bP2$n}Yu@v~0p{NI02l2Z zaMcCdxM*jei+66}!acj#F&MBkw?$N-(S__{p!nim!Xkd9>4ml$t{qM~@IA+N+py91(|0ThNsF zy+8al9)7T;UoGI$1i}E(K0%KGMwu-RUJC9#vYj{o!ma%D|MennzHR}=HKTGnnhsI= z6(ns%lAx)@Y{a(SBm9T2*?~(J@IOCnZ0l!MnMMMJQlt#-bxtU>pluV1GKZaQ zh$+$i+ndmf4sn?n&TLH3iZ=Gp>Iy!+nz-w48v-jxuaEZ6qbM87uuMo>P?Ui^$r{R< z@8N8LB1N{fCkal8TH#}cqeT%!rkQ}j97dlI=MZA%vh&A@DN8`8&SfnQmzl#|G$br$ z@VhR9PP2xG`J4tQYAo%fjSI|$RgyML{WNm~Ca9uPYNEAuTI*vrATWoeG1{B5mM-Au z-+hFG2R_XAecJ_Guww!zw_>TJ@d1xxpe<#X00qGoX-^#H4KLcp#e*C9!C$_O4;~&c zzu-uz1xfVdh{Z5rwZBLcdJI!RO4AHwdX|;d86;m2QYU7Gc+4X6eW7%Yx^43HjTTW& zscXa}uqL+lYmbIeeZGbGxRdl)z+IKpZ|S*Jj;%Ys{l7lU2cB&3!#Rd=3|&s^nc{W+ zAq-&di6q}B|LeU-I-eDN(mAF}0{B23}d{ zd*;fLrC~v@D(Ut5sPn{>sN2Z2X;~XhSshIo*Dd2F5MsntJ^Dq3NSOtbOfbq$lqjJ# z#iuR=>UapFf=h;`4wH43xjp-9=l1wE<2X;dMk2IHa8lrLNIOLeWi2JP#rygGum60$ z<~27UlQkv@iXxyPqM=7!)lADNWfHuWl5r%C|lq@n+rkdb| zbuY}dMv)-6mi6g`tqTMG!?(YNe|+sW()cju6M-p3aA;A|TD}%_JE7NGU@dOtXMXvw z_{|R;L$>V0TSK8rXj;&Y%DkKA{u}jJ>Vq|iMkdbTQe>{}sH+qvt^hoqoU+M%+eVZ`lteGq zs0c^br(Ac*B0u;qzm%KKYjD$(hy;RUD3p!Civu%)FKA2{4u;%${|SElZGXcDj`g6w zNEuFK7IAY#DM?W%TS2^$BtE12d-IjbkfjV83O)cOng)dNqkQ8R?c;~O;RdGD22qDg z;fM#X|wU}k& z@oZ9n{}B1YTpG`qJJZbYoFo$^DlX>*Qg-9j_dpAp>4-wfIXh<^eoye#JotI6+JgN5 z?7erqE!SD!{e4!MnO#n~y-8PHR+r@>S1dQ&G0k)mV`2y`yeYhp5JKLB@RC4Y0-+d! z4aUYc28=Oa48{epk>n~_vMj4h_g>xJPT6H<)>_Z|$C`cak#w(`F)>M&KBId+`|Q2X z%$haNddlzl{mKp0(qq(ER$Iq`laT|*1A7j)JaBNro@&wV64% z6adW%dkWDQ8fT~iSk0ASt%e+_2i$hkr?_zEBIlpANZYg+S5c6Q9IX=~0pozCWzGrL zUU>y~ohZ2N{sZ)UNooR4ItUlJxUs%FU@dKQnlM-;ubtF|ST355n1CF*s&=Fr3aV5Q z5G7b}I>ksJ$Pj~c^!hDUAV`&wdYS>d0{CQ!IHI^rW+4f+6B1LbBuExb4w9#CTBegR zDYX;=P7)z3(@aLB)?m9_qBR!5qir8+YDC9OU4a3HZRqfG%EV<^2aCa_gwd44W)V~z zeQnSbXg8e8gNJKw{^ZSEw99htSsRfw1(Rso2Iu-nSK$u`)*0;bakg$<;OZ-`;WK-d z*n8jz!!naF1XH2`yfOHWNn^${_;^S5{R}e@Of(U}VzeZMV_bL5E}nhKX4)+Jh9R9) z)m0U?T37T&tJBZgfBVm0`$}Wv5>278!YE;?Bxc?-LZ#sP+b4Yfz!<4|lyRDqi7GrH z3%KqvWj$e^kV+V;WjvV@jioULH6DyZ znl@v!2o$k{8F)o)qOe1(D-dUpLNNunN-;&i_=cj&I>Lq)M?^x4u?-OMZJ!cD!4y~p zQ`4Frwe2ynCCMawiVWMl*Qt5zIeQ|+2D9&3*G`TqI1#GYQb@~jsc_8!e(u{g^ZM6b zMQBG%(f}($mBvhy5WT}O%0(Mr5^RrO|Kol9$9FF?8YulTW8f;r7&%TO%ES*i*&6}{ z8>d3!gi+y`+BsYj7Se>(W+Q)n+esFmqC9oi2Hc5N#9A6jsPFR8(xcRh7y_lAaNXrY zmJaUW#`{`^{gOc((E#ck7$K5bWLzhr6jN3L)Gl#6>u-*dtNutPuXVIEPN=OV+1z>I zQXrb#j~lEfI8XvDS@)!`$0@YIX+X$2%R-{{%Ge7JcYbETd*XbdFe^ zq!l5U9#xuRFs%A{f>gNZnM+d&Z3(ueHCgm4x5H+*S1pcUOQ=`q7XwbL&hg<}_hL3$ zo^e5eoAmM1A;)@_;4MA{#0W7a)XYO$vw8D~mp-M(%HF-)xF4!s4{HLww#6r*HZ%70 zGq35>uld}p(5ax3Y$OB2D`=1Kif3QI)w}zo7*H!%;&CO#h;FvND2|_4{ji%h&Ar2Z z1<*$sbdY^W5$_E1b3>G)+3wLbzKiJ=&EHQ*`^1M5OxD{lD)?}!EiD(`mCPcX1HTP0Fn*pJnR*1D0YaqnX$tG&2lhGx$yAV=|ggRlQ>wbueldK1v zkeq}O%^*!pcT{__hd>jh zS5ins6DG`kY+b6{Z-u8hn@|(Z=ar$l-(RkV@nLb|}R*Edw7!pIBV3VOSg3#hs zGsCt@7US^^N0*NCv%h>d^WXC|yz;5LXqS%SjmLy;&P8dHVvM6{YI>#Rhu`oDR_*nC z^izA7^mmdt6HMx zGBpOgC76PfO=8cXDWCe>UjFPOpW)6uM{x6tG%TPPO!BCJl?F9R1;oI>C+Ze{=`A0GV^8DtFMAbzvqGWEx#d%RC!eVqM${^4T(f24B0us2 z-^%27KF(j=@C8zDBkC$t93f08onOz9ckv}2>Rw$E{nTUV#JFn%tzUE@4lj8_2#u%(dk})*S<5dF>4ZPW05^1?GczmtRbX=*e>Allp2|fD~KjW-s5%LRa|=z6=yBc z9!D!-|LRrTbn7i#d3Ip;xw~Q7U_&mNm=ICdUrTwQt86S!^1REpaOY9whW!Qo3M6&y zU#1iJQNpCVIsLiC|0*Z4zEAQ`-_H zp~ezJ%Z6U$t1iEglgHqeyHCQ%}J?b z;FkFM7hb_Ri*0srjK!pQ0Fk*UEEGkL`|sKJYxa%b`_gC5ZS1{@wow<;N$|BwvDHXK ziUIc>Ug49Ue-LZ?_|T%pFmLIupf-VIVu?(n z?>hnNT)auMK28-xv)HxFASxZ0_mRg;<6{P-5Q2fJ&F~5r4KDO?(t;TiC6fCmD;P3u0_rx$>A+W3Ncy4^pG^c5_89octUF!^a`%MayQ5J+`%pT#-wV2`FH{&iJ*q$9YGSo&3Ri*7>!-dFy~ii*n~{7uhfOEspMOlhLS#vUd)3Lh!eBe6#;TB0|kRMCjAk|Ns& zL$1B*LblKKxcTk_oE#Z!HOEw2v|Pc)f-+Wk6@-Syk23*7DydRHR0%esT7VRpsw@f7 zcvM=PPsGaciM#LP-s6!gF1?yTnLv)ACi4{fc0?fsn&v=jG%eHbPkF}Ece6TO;`TfC zVzrM%hf<*wluVJ-K;bBYrymN&)iSM1p#3aON{&7Ra6nQArbJ~q7ja_2w%(N2z4$7& zmZP=Qj)nb_V$Z6~oBjfyyk-A8&B>K<)LC0-R#OT&AL2lS4fBrDW=KSazI38be+Opj z%RAP4IJq4zE7nWL*nDb7M>QkqjmeUl2xY%dZ*E8uAv!~l^j*wcj$&2)^9 z6Ta{D-@xl$@hsfr7!n&w?=a$WL?McEhQj6Ke01}?;}1W?TYv9^Ow9t`Wor7Cj<#5{ zR*7URR@WPSia4{Y<hZI``Yt~8g*{O8P-AFQ$C;-&7>yy} zOi7%K+16|Lmv49_&w1*F)RQHQ$wo>LLX1T1NSh>YX4aau`_}PjoVugke4Fmy428?H zDXQzJxcQ#F$@c!-UE;{NNj2!)sn~Eq?g`VSFNQydq#qFa@*` zQbMd{CetduKzxCBjXIZu4}=+!j7?!|nX+ufB^T%K=yPGSjpsq$qO=NwYYabBpl^=x zPrvi!eB;ZX1NBNS+*sQ^IWwpoq!5urw^o#giW_t)+g%IY?71>V&=eW=dkp$z#{XrP zKN*n#A{K$9967!c?Kgk@Gq1Styp3O{O@mdts^Uf}Y!GRkah~Og@aLa7%xWwsj1tKP z@Kiwd$YIU^lzuc$u)nYH_|g83b_sInLZ!lcltc(^r+3;t2)3iVN&=ndb-Mq$znBk` z*^4^=Df=>!@jwA)ikBCM9Pb6ZU!=(yh!W(5ep0e3B{FiTi8sGdi zyNTf#ZtStn5IKP~p2Ad&%G{aa!i2gym$$zEUf%LMe?#!wa{HHuU?zwg<(_AWh&u9q z?U?bNJmxkkr+yF-oPh(AMLzcVRjxi~j09s1-f5~(5@K5<4Z00!+HH^tG`AfllL$7TCeo`H5F#Q| zRtrO83g#z{x!O>flG_fh@~L|c@U(L;W9Rmb$m$A9ejeiuMY|81wvgIQOuQgzmBqM^ zXI`Au$ftkHzUmcEJ@3j(cD|OTEl^v7ov3JEh&^L1 zO)&iB=MHmZWGJ1@o|MhSg(vzbKe3jrFVA>1Tw>mROh=%ZFkF$m_4; zb+5XHc61z@65?tQMPf;`KI7C*~fqnA*v9w3<#u|jIccg9i@q z`8)S;-UVl|>#R-4beb`uu17%8B7&41qYzl@xcZ9ANlduw_FI8ItsRgOR1#^WA=rSZ z$*F`cY7#8bDuP8Kq)y`1#3{yA~l+fF*=;x;J^Cf689d9l-_mtsK89zZv8@-9Vw+pynZ^b zaQ$b#A}Y#qismctD0EE29zfAp@^XrdS#Ti;0O;)YR%pcAkI#hG+5ZFWrfZ9>C);ArX4ZXb@bp$k;_< zVJIS4SMa-kdOiR7cW-54&c@me8OvxQTI2-15^!QM$sxEk&*IeSo_QYDcc`qNmohF> z7M?qnw(|Ke+{P8>zpoX2{OUA z3y36)G;8S+C_pmjph*>iWm=9w6q_={%&Vhz9$Z04P$j_?^W1(MZoBg{Tz1|(=WJZS z)k>mAQ;ccM7?U>Pq#}lzLRzZk08e}RIfxnZ+0PwfQf>m{D8mw|iqvL85sF-JB$3&~ zJ;6W@tW$^NP+SV2o@uar^)oi|tc!<`COO5U_C!+SKsMRJ+NB9Mz2{Fp{%(846}#Pa z&p!VehhEm=EJmkBQjORRF)2L{H{7$rZ4XW^*m+MFzYm;pTP4Sp1l>nn{=!N@wQ zB$JKcOdG;FhcVE`+(u=L=_uXL{C$t``ew&tjK?!Cr+R$PV1PxyhRivKCkaH?P(-0> z9aE>&R;Z)0OYY~VUVkay`08tztR4qjkh~B~qO1o5Q!=U|RY(ky#q@^!w?Es<|MPqI zVkziXo*!|b_GqAqgb>ROjHZXV?y8bEzT`p}PZ^h6FnCH$NX+1G zD-EeA84b4ZK-|V1NBZnN(kBkjrW$O*he)&|q-;n!1}ylkIbYY-Y~Q?z2lkxg=6hDN z+9!&S9w#%F_byjXkwauMOiPFq$wDkMklTz=sS#5XlOm=^4AZQgyjXHzso{oOPq5>h zb2#tp0jAl5SMXZmx&n)}6|GvLDIu)#w9C)OIpyYC@4$*7sii>>Q)ab2&7OH)@-5Le zAi0w!SxX{OB#Ww$)befDZQ|lxom;&S0bd$E^T3qP-ZR2lj}b*Biz7c#1NcOK_djFIdP3n8WqJm6 zMvMqmnUlJq&LCWC91qt%SU*ljxAgdp+ZlgL_5e@)e11&TM;RF>-+wiU;6zU85g@5Is+*Yu^<6! zLq|U*xtm01`Ka7skdIshIh_G~=;fTsql%DWMYcf1plOP$h8$jQx$|?M;F1f@=B%?e zlA4+n6V^mx8!*<>@vNYX8TLyqz4R(>-E)NfM~>osh2S!OUdK)xMZ3ywrjwe1q}Qfg zSu>E2>%Kk8?Nl*LwZNqleDBvijjMM!Qc|XN4x3tBY!K&2DHAJ9+#J9Ep_}-nw|#&= z{_`98>+5gkW1qZRXx9xEB9B_h;ujt_rkKW&voseH)MjL{tC zs%XX{lE~T~dE_!#rfU$Bl`{E zAp%iQmx*X4Hi#yka`{!<_uwk`K5!6IF68h(g^ry(EC1yspK{knhuTOc-={)-F1i@Q zcf4dLXKykj5;hr(+5^c*4`FD_g8S}2_Llek*(d+j96Gcbr>o%z6ksfon2e!~DOW{F zQ+97IsC*!4rnf#__D^#K`H^|GGray-zx$st9vaeQyjV&JtR<4G!M))ez4gU?0KwfeQY$mAu~Havhb0s+(4&D7V%8ufYwHwMCK-aP|KuHRwsd>_x#s? zauwhHl8vyshhC9rLxYON7>6nxt@@0`u@H0O=52rU5&q2|DbwNtHpmh=h_hH3V`UYS zT1)_+N_?y^TA=DWC{iZWOr)-#{2>?Eo%h3`J@(Y~iqcA?MX}n$hnkHt=K7V3`R?D? z!yR8-!uJIqPG(X8F+|f6sfhw9?0|90C7bWyXWw`M&%DquS*;OU(g3EB9QOO{9f^^= z>08(7zRYOqPt7+*){dE{H@gpO_v4KB5{&Vzh5K3`l;6 zSmn)scoYBex9;OJ2hL-^xrF=N*?jWoB0u@|TX@qSev-y5V64GKhfNXZR`C&BQ{hwO zDQCgft(NHsG|r)Bj7S5q>=fZTAU+9Q<0G^gm@ZSiIlT+K8N zu&JbsLNtliMw02^M9F4~f5}icjco@x^TcUn(;RG{CwE?vofuMVloXR1u%07_R}YRy zh&g_8%83(gFup>y1x*r@1|z0pK22=j&}Zl~jZkjPete@4o#KfUpp ze9eoWOPs<)N+dWcHMn}zQA!oq<~aQdTDyZ^{QZ0Rh4*}cpL1z(`Sfa#Gi7+pqJ*OP?g)MlOal02*e0#6hg~}#UVSk4G1A(C@>bHrVf&{ezx<) z7y^ctXvmaaTH!m6%f>|-sfapHzv)r6h9rs)FbI~)*No~_e(u*l#C!kZZlst81JOt7 z-V`+AL-qiTMyMmZcWh;DI6$R9Q@~3$ZW)p@?Fxt%(E^Q`!p>O(u_bBF1#VeT2L0S? zqX|@L5Q%~qV+|zH0|%EvU880+32D#113(HV&=ffy2qXTA(r+wzWg{u%%MJ$G`*UrJ#n zc*)f{Po|#|GG=S$m*z7~odIk+1DHCm&Ldqj81M)U*)v_MLI%lFQ@cPi70xa)Pt9Df z;$ue_`KkZ*e)imR2s`Lg$Aq;NQ9Vf<#*7hbn5ISS%0VvOyv)D-r(exe&e_OhJVHuK z5~XND2I+JcA~xgHjLEpgq}_V>JMaOFC@zfI(XZ$imc}$-p=h&2C@$e_q*cd9Z$85Q z=@!&2VC&4#orpBCLA~c_*vO}EIzng)qAkIBQcRf^O9V7gS=g}PvDi$qpdsfzPn%1g z8Nfgkh&Z6YrQ8zOSf!PMqyx&Ppa?l@TB?Onz?%j)?6ZHom3t5OXeo2}>H^bZf)S-i zf@nC^MHR-GfFsU=?rC=}DHp5O44}^ikq*O}T@XV`iF0=jnIB}qs@w)5t3Ux%h!C5& z{J@^WD=9flN-o{CZ}~xzyW40GNOQ@!fnc*@4Z@a%irtHfruLM#@|Bgod&GD$qzezJ z^6A5m{4^wEXj2A=R>L9Xa6qz#76oge>y!SwB8`0_=Pe>@5bL}@B`htKYjLJ!xlJq< z`}ps#-^w>!y9=GJvRch!jbk{Tk_u1l3Ys)eVIzecC$u~GnRgxJ$3A!*%90HwVCHEx z5$%XJB%JSoWPon&w(j6krbSI4nRc!F0bB;g>f^h%d=)d0w4pWwCbEGhZKigCwm8OA z3J7qy8S|MF@Q>d5AYZ)4GglOtcnqIT;%v(@bHtd~V2=^fCYtdkuH3N7|M$j=dDgB& zG)pV^WGGC7l~W*hRlpj=*?e^8oMs;FK4j2S9@DqFqI)b zO^B0a4zD_l4T!9OA7>m~>>;tmStaQ_M@}vi+7X3G*w9j_!AgzT6)*`S6+Q1LCE&F| zWA5|_CX3kT1lOzpl4uzc7}872I9Vl_e9b0V%GT$UiU-^{SB=94T2^txlT4b;G)s}6 zc=;Co)vM2@NJkhK69R^!-T+z<9N?5DDfM8;T`P_Q6G!Eypq>~vVA~-!43RXnI12C|&UpADT`P>FwB`{+&Afthb8}R^YAyel z`%BH^%4%FC{MI*d;eV?*JA5J=(((vWCyPD!R< zDw}xAAAXGAdGAND)dJQRXiBs(VQi86_MD-Oam_%j&rNoXd(7m3W)0h1(YR7vKGL zTzzhzX7wnosfekoc06nt(@{-5mP8+q@BXPO$t0%1qSD6*Xv~fV#t<~ieR>CT1sEdM z8=?e~Mm&bXSEL9oDOF$b{eVP>A)(e}S^ywNlStP${K$J@P+AGp!^o1h%$M+sKX5tU@|+>l z@e-}~7$+2y!-#WC?SN!XV2hI&cY;+};J>~1LH^?h4lwPVLqDXXSm8!2K1CCduj$sImA#7(jzh#62p+FqNNeXQB%?A znBj23pB-N0pZ$;9ICg)7^oM9_=rICqnc4xROL(1wW4wH5=yie|VP!Yyu;?PpXcy%7kGDasvEUko$7!EbwHC%V` zBHkpL7?_k9_gB=4wmm{9nFp?V>L%=PKeln$R55YNR2&U`uq919VYIY_)f%5k9MR-K z0!q$tcV|YXV9T71wG6Sz&`+xvnSwMVTVrHIsjEz^C2Lomq@`fFUF7>-GvMF6u1Bf& z(#A1JK{+ibl7eZ_SQ1kou`PJzcmM1r{`k{@SZ%?umn2hKEpV(-=oAUVwJwlM8j)%K ztym-_R2}nkeRgddSNwg*srOP=2jv6OpkQ$Lp z50#%sOHhwdkBpVG{0V;Qhpyu#&$)up%1K%))RPuNW&=hWJQG~w31$-y%srJ~c=rvw z?OlJyB7MAi6o;{vkXj_=USK0_uIQx}(e5CwowRg4)<)B*G=gN&qGU2F^!h_p9(u3V zj?^8Iq#KB70h?-Cl(BI%Hen<&kc3SoCeE?Z7`}M-UcUb=pW&8$g0JSFO^^(AbR82) zi`Wsi3{2aaOU~ZTPk+~odBNg?H1&Q~OM}yhj}adXs)k@PbF&r>V=9OR1b=Ehsh%c% za_04kLf<;}9baM3xCLXu6-WTz3N9L2yNPz%^4#Zb{(=#dIAdQ4&)FjR(sz z-mKDAyZFryo!}RK_ZBpc@Y0}Z4iSqnHLbCEj$z#JDNK2BMWHW@uVyHREwoL=h z8O%YL5~*p6CPpt!idIa@QO8{mGCna{6<>@o)){*_MwF;eTAZeg1irgTt2IDK5I%D^KEuYS(in9)JJ7m_!b@utIhY+w2$EfH-#F6fx=`6_jG}{f&I|e&y$W<2@Wc zd;;SNL=EEm89ZweF^x$yP#ARO7+38o_~{?`TF&0N$jNq$hMH0gPIDC)YY9e4CPP?^ zDalmy*_a8B++0uREUa~sq=-d0a&nbB4;+D_B*lcm;F93f66lk}P^Aa?=l|%d`L@@- zoPixOZ37dgl$GIIUj1^u|GQqy2H%ouPau&@g0{uas=+a`@4yN69!IB7Ml^ujHI#&u?;FY(T&9JItnC}pgDw^ z&4sSd13oN5f3z09$K&;%q9#~p9LeT3>M3!f7&GB*&nmYbRX%obju&5c78~XTKRu2K zU~sghB9yq$BiI(=m>mm_XP-CVf!l6o|G1(qHn6IhZr;Qx&W4CFf>A440*>Vm-x5uJjf(^`hJ#mJ<}R___|p4EUBPc^Rj2}cuxxE<6x**`f9Saz3zU(3J-`DuNxCTvWZ867`lg3rQVlX#EO-H5*82hDYDR)fCsmP#J z3E3l*j*=+~nq3mq7EJ0XyM{~r==Z&ruYSQ(i6@rOrX}^pBpa~70v<62YbRg~BfpJb zdD}<%!#}=}vfK<*xS~RgVyqHkle6gSazy$6raN-W*rSm{Jl6F;9$@@KjO2R5+Wl_! z`PAJPV+{+#1-@|GUHtnuy^s5jxm?h3lDi{m#?vx?$r+Y(%j zbq&d6#W-qV_x8=a=y{j%(idOOQ_ejbZJ>z-YOCA>ECDAuDfS5%D8-wyQD;J6O0*iXtl-A(%ML460OPnfK60}@i5=@@+)}5>t2S7E$yU1 z{4nPa!zk+>y9$S(Az{nSy#M;Gw;~D2ZtX{*jWq_Z&ZQY$<%v7Zs9Pg{F4ijggtVIp*rK=IOaW zN;4GpKfLjG-F;%jtZ|m95h58P@*(9R$@v(cYjrxEZeQF_baaGbaO z@g7EY2*rcMG(>basW`~g9w+(&4cm~_ZAhyS9)QX9#FaowyBViljO{$ZCRCTOI;DyW zXl*Ea1@HcgTe;_i!wm+sDJM}>a=(Oj`Z-DHh`aYcJxbzGf5i)BDL>WAjXPk=A(_PN)&;RAORF+4ubQhd#$o{q|lChD-5& zgi=3YWJ%rwEm1vFHEdlpT)Mr2w2X=)M4udSB+}L?1;rKYJ+yk;v7;*^>xzyH8O6Gi z!$+3%o(GPMrRXUs8c9uMB@r1kQ6r>PF5a=g_PLTt({>B)AHEm(rH&l(>8YQM&;miA z=nv=*28g#rr6Xds*-KdGNIe!$LMCyo!HfywX^kP+DZMr@Uk7T_V26g0OxRM~%71>t z`MmPF=hLpN5GB$Kp{@#w@mZv1K-;tQbcMc4On3_a;cXA_%OAKKTTBpNQ>R2EVhSM= zbJahVnAAg3uWL*fkOnmq)YKgV_ahDXLtlD$-tUof`B=wk=Z6eYniaWb9$<_S=Pb6I zV;U?whQcRqxts5O^PSwcbPh7uL`+91t;2_Trf~&R)byH$$_wLW$}@Hj`1v&; zsi8QL*-&B+)skpVBEuzm?Fb)AFgr1JfQ=J+X@x3{kQk7(4dMbp_rs=byyugFKlz*c zpy;D%6^me#LzrYwI8~@bqOCzoL<_7cquvV3y%mxH)Z*KMs;Q`AkEW^UpL~Fy`I=x)Qr#L7rCmD*O4_3`#Q_D{y+AYyFp@(~f7D>!j-CER=e{=JAf zYK<5{H)x7;s{W)ngNBAJ3ne?ZEYQY>a;5$6JZ3F#B3*|7+TpiyC9O9YP*zp;6lYA- zPwp>)gbrQ;NW^Qzr-<>EmB|vAnjik2*YJ%mxsbSY3=u`V&7LAknT8-eYDpAUNl|&r z+djZM-~V~q!MPN@f~d_}@up2JA~Oy|2g&77TxWE+9ksulUeNmV-rVCefM*(~^_253 z*6^AkgpAj-mKY;#NQm`FY#DCY&R^VmlAr(e_i^~x1l#w}wz*<%5@?H$3GLL)?DPaz zY#09R_rI3Q&fUgjd4*(!kQ_<-nUPsr!i-?n`qQEd&U78d7!X5<2FyIGI>-Nb$6s>O z&G%Ez&y%D<+nD(aahW13jkC5#vYp{@7!;??n*6550vb!TameTH-pjAP{lkpHJjVA? zZL&5>0wLrTsSYHe5`ZiXAV6>}jcZ9rHufsGWNAk&MCAv*^XquytFK~EA0jprl5tq) z(G)PI#+XD3io@V~Lq7WH+xaiQ^#S&UO;r6pv7TVagcMK4p%MgZElT3Tvlkf@N{WG$ zA_&Wp>_(IYOriz;e*`I1(zv|1R|Viate zqRj?t3`J3K_o0SQ+`UZcA9^CEgP1&#oY(r#JnkIOSHQ3{!Io2HPU!MsvqXL23lyPl zS`@)rmq}FtkDY68hQ3@ykMk&wd-HCNR}&fZ>w|7I@*MTUc0Vk@f^G_HZ#jh?*SU^k_tgZNpjp$TKgU=gvL%a_5mD-dPGA z(dv+B7En7`>lP9**-@f|Na2OTW|!>qMqS8*wm zrq*JR5UEm4Sm^Vv&#dsHZ#_n>p1wOl5KF{Ug`AJowdd#Y9jXFiQEjPg;GevFjte$z zK-!qKW~@1AqU|RQ3sNPHt_*+Yt?&5gdskM?lx|(x9Xoeqk(ainDyU!Z+)K{w593Qy zs1n#?D)rb3+XRdCo)b;MU*B@Ldsa_XGapjDd=kd#@qXqj#qiC>THfL`rzJuMzgN5~ zaNbk58MkC?-uY+UCFV4V{q;Z3x+zRDIDr&flD9M=&hKJ}oirP{jzVdsCz1_&BJ=v4s>T|! zM#_4RPr4L|D$G;_*B<=G2n!!=JYFB+A>X4pVzDCBp~bsCY7F}x+{50z_jA>iSF*8Z zAm(I7v{f$jg?zovTAF&y#w}a8^s>vi_r7~LbodCqw<&vyGwfsPYW&t>t<7P0h?!NX zdv4d~xn|FMG67MCr69BogR;-^@g+WU+igg(z^+~A(JL27#u4HKV_U>UhzYBLlLpms zo}HqHmYZ3YExh-mck|zV`@`IKWI}&#aV>wF&2uEPJLlps%}5cn#GY^c_sl!t>FajPX4a zLlBKb6NqlWxE22OcU-{tzv^<_p@XDi3yt*fdIUqnwGCo6BI+qP2448ZcYK&%{`31u z{oNE&z!V#>}4C8RQu+tE;b~A<(|h( zm)X@H^SmoJbNhoMZrNWlU$l6sX?1|L29b!Oi0x?0a#f^T3o}`&Q@2+|iMGK;N1+uY zPr{*tk|V1FZurcYd+$6%*`H(Mrh>UGOGq`wCc&%6hlKS9vGY?*xs8)k%Z;}l;lKax z=lJcvJixv%$6#&&LP9lR0I!f4dLlMeIrKGClmfJi03hS{#6nQ2STGqiY}m1ppZd0E z@U_(6R|5Q>-?4 zZ97zhdqPRIq&b3$?JCc{WPxvc;Z9N$P%TiCMnYP(T5L>``nK=G2S0Mp+yDAwcidT4 z15_ibDH}jYh!K`o8ol_EtqWIOe%_0kn#IDYlepZbR0fq|Xqiy-2HbN0QSN?l30w5n zGFnf@=>5+aPd0=;<7f2LKM&1rWzR^uP&TD(@N8KEiP*Npl)Aps&M3&k>R@u}?>eJM zoKUz(5X(fiQLNm zetfYD=laxX1z}3hOQsGYB33b-fKjHh&xDg%@H=&$qFs|kkgkfjUiKh+gE<^r{~EB) zKt8rtc!ueI-1kUOCZ@;K^wHSMVY6RBqTp~0ymH?I`}yp_f~Q?_FU4j*3wS6NUJ&VJ zV;Cp&{2Ymzu{(n41+*A*&;FW^e|j&U{lbLX9~2HvHgKrf$l-R81Jil#Jiduf+++BI z>+j~R@BcjS`ry5M?!oN0__D-JYkDyeZ1z-Lv;-qr%)Yy~`P4jM@!E;5i{#>m2;I)uUES88vP~j7{3V-Y5&bf=e&h!iR1? zlFRt!u}MI`QpaQE8^4s#2nUb>AVW#G*T~%xLx4Iv4!?-gv zZH*d3U5|Og*T01CdEJGSx(h{+$#UxN)l2S`Z5no!GqU0@a{UGo9ll#%(`GDyb zf#3^Em`*X|2UaEK5Ct=a0v%71y&yZNc_dkNb&8Z@<}6i{E#ra)zKcaL!%9go>LKj25c z`(=#(;9fp><6RW4hxZi|Rh;R<-;^a(o%d*LyVkddT{}XI24@r2L2MetCv0Vrm@>w) zzkz8oeCCcNZo20lykDkY8A_WywDHukQrFa`0hvebR%EUOBg9za#o#3}kqBxtXt-l{ zP9}BPj9C>wrwR~gyR|u5ImtCoIg5YyhG%i*_G2(TNJolfW203dK5>ydq!7F#%b$sdIv3SSMEe&xz#iU_U6B==? z(uz<|xZ?apHY^S~I*Rm4*WJkOhOS%8vnUWAedqp%Jl61Drzv*kgmZpFGTnpI`dToA|{Ktx^s*QOHR~Wx^!GVzWrojF8gNHYH9o2b-z~ElC~Ky~->po*r_o zxdkp)5oa<2##j_#)I>rPF~(4Ok8>az%^ewtARb4H54)IXqNDr zxMQ$0tE5dEqKO#W!-oN;9&@%=-g|$Kdw=@@-ttejuzT|&?R1O{hcVWpc8H%=n5Ffl4C5B$foC7%T~k z61DTZ+(CRQkahuN6>Aln49OTKs-)2>uX*NK{P?$S=bWu4Y3e|5J)*G`CgR5-x7mq+ z_e@=%1GwN|HmB;?RHR%Ncfg@B0>XNB2LY>Xw+WTRqB zBo_Eo(v0`<%IELm|Nfqrve89oYJyclj98OJvaPkaV8HenSKIilcl{0Tdf%rBZY$P# zL_np*YraoOy99K`Ph?LH>E4&f{h%F*139;qL)#gWTXUB=;8wDM+0F#A88ZPIqInNV_5U0^fa0Zv-3d3v9p`sdfNt2l%# zO|ZtZT;%GmiFtlaO1Rb#stvs9eV^jDfA2*bvS)8a z&t)#ZP&Ap4Kw~ZrB#U)@V${r(OoNb^njy(X)HIm%NG+5{TSEf0*3no;5gQDhPO{GK zTc&&p7z%;HR@{1^&wcmY!!s|gm|yUi7Mzv@JB6}EBx0hBU6=oibp`10L|tN7GC zN1*9pt!LFVS+mv%Hrc#Ei4fG&QeZToCZRS%iky*-hTkA5Yhgs2avl~CTAT(fiPqXo zNTGT2NRqPVh8R#wR<^cX$m=lCP*4*QR$UKM1SC!9g&wFP#gwU6qOAy%0aY{Rd%xjo z{>`^u%?5jvx^5_J2`zZ~n3&N4)(NSdAcKNsm-zMH`yjvezWq$yx%7PwHKW*EOp}Oo z#yc2`>AcZadPK3EeTX0ln;cObt+7~XurW}^MC%N#brcwyw!sbpKlIXzxOjU-Z6>+M z)5yNyie5xE7UDQIZr}Ts-}}qA9XY^i;rpaD+^X%0APJ1gJZ^0>q4Iw8q8DCpUV(jq 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 z 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 \ No newline at end of file diff --git a/frontend/public/robots.txt b/frontend/public/robots.txt new file mode 100644 index 0000000..6018e70 --- /dev/null +++ b/frontend/public/robots.txt @@ -0,0 +1,14 @@ +User-agent: Googlebot +Allow: / + +User-agent: Bingbot +Allow: / + +User-agent: Twitterbot +Allow: / + +User-agent: facebookexternalhit +Allow: / + +User-agent: * +Allow: / diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..b9d355d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,42 @@ +#root { + max-width: 1280px; + margin: 0 auto; + padding: 2rem; + text-align: center; +} + +.logo { + height: 6em; + padding: 1.5em; + will-change: filter; + transition: filter 300ms; +} +.logo:hover { + filter: drop-shadow(0 0 2em #646cffaa); +} +.logo.react:hover { + filter: drop-shadow(0 0 2em #61dafbaa); +} + +@keyframes logo-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: no-preference) { + a:nth-of-type(2) .logo { + animation: logo-spin infinite 20s linear; + } +} + +.card { + padding: 2em; +} + +.read-the-docs { + color: #888; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..0194362 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,72 @@ +import { Toaster } from "@/components/ui/toaster"; +import { Toaster as Sonner } from "@/components/ui/sonner"; +import { TooltipProvider } from "@/components/ui/tooltip"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { HelmetProvider } from "react-helmet-async"; +import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom"; +import Layout from "@/components/Layout"; +import { AuthProvider } from "@/contexts/AuthContext"; +import AdminLayout from "@/pages/AdminLayout"; +import AdminUsers from "@/pages/AdminUsers"; +import AdminEvents from "@/pages/AdminEvents"; +import AdminEventEdit from "@/pages/AdminEventEdit"; +import AdminEventDetail from "@/pages/AdminEventDetail"; +import AboutUs from "@/pages/AboutUs"; +import Auth from "@/pages/Auth"; +import Blog from "@/pages/Blog"; +import EventDetail from "@/pages/EventDetail"; +import EventFreeSuccessPage from "@/pages/EventFreeSuccessPage"; +import Events from "@/pages/Events"; +import Home from "@/pages/Home"; +import Logout from "@/pages/Logout"; +import NotFound from "@/pages/NotFound"; +import PaymentResult from "@/pages/PaymentResult"; +import Profile from "@/pages/Profile"; +import ResetPasswordConfirm from "@/pages/ResetPasswordConfirm"; +import ResetPasswordRequest from "@/pages/ResetPasswordRequest"; +import VerifyEmail from "@/pages/VerifyEmail"; + +const queryClient = new QueryClient(); + +const App = () => ( + + + + + + + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + }> + } /> + } /> + } /> + } /> + } /> + + + + } /> + + + + + + +); + +export default App; diff --git a/frontend/src/components/CouponDialogFa.tsx b/frontend/src/components/CouponDialogFa.tsx new file mode 100644 index 0000000..359b0ea --- /dev/null +++ b/frontend/src/components/CouponDialogFa.tsx @@ -0,0 +1,136 @@ +import * as React from "react"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { cn, formatNumberPersian, formatToman, resolveErrorMessage } from "@/lib/utils"; + +type RawVerifyResult = { + discount_amount: number; + final_price: number; +}; + +type Normalized = { + valid: boolean; + discount_amount: number; + final_price: number; + message_fa: string; +}; + +type Props = { + open: boolean; + onOpenChange: (v: boolean) => void; + basePrice: number; // مبلغ اولیه رویداد + onVerifyCouponRaw: (code: string) => Promise; + onContinue: (coupon?: string, finalPrice?: number) => void; // ادامه‌ی جریان ثبت‌نام/پرداخت +}; + +export default function CouponDialogFa({ + open, + onOpenChange, + basePrice, + onVerifyCouponRaw, + onContinue, +}: Props) { + const [code, setCode] = React.useState(""); + const [verifying, setVerifying] = React.useState(false); + const [res, setRes] = React.useState(null); + + // اگر نتیجه نداریم، قیمت نهایی = قیمت پایه + const finalPrice = res?.final_price ?? basePrice / 10; + + const handleVerify = async () => { + if (!code) return; + try { + setVerifying(true); + + // فراخوانی تابع خام که فقط خروجی بک‌اند را می‌دهد + const raw = await onVerifyCouponRaw(code); + + // --- نرمالایز داخل همین کامپوننت --- + setRes({ + valid: true, + discount_amount: (raw.discount_amount ?? 0) / 10, + final_price: (raw.final_price ?? basePrice) / 10, + message_fa: "کد تخفیف با موفقیت اعمال شد", + }); + } catch (error) { + setRes({ + valid: false, + discount_amount: 0, + final_price: basePrice / 10, // برگرداندن قیمت به حالت اولیه + message_fa: resolveErrorMessage(error, "کد تخفیف معتبر نیست"), + }); + } finally { + setVerifying(false); + } + }; + + return ( +

+ + + کد تخفیف + + +
+
+ +
+ setCode(e.target.value)} + placeholder="مثلاً OFF20" + className="text-right" + /> + +
+ + {/* پیام زیر اینپوت: موفق/نامعتبر */} + {res && ( +

+ {res.message_fa} +

+ )} +
+ +
+
+ قیمت اولیه + {formatToman(basePrice)} +
+ + {res?.discount_amount ? ( +
+ تخفیف + + - {formatNumberPersian(res.discount_amount)} تومان + +
+ ) : null} + +
+ قیمت نهایی + {formatNumberPersian(finalPrice)} تومان +
+
+
+ + +
+ + +
+
+
+
+ ); +} diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx new file mode 100644 index 0000000..984cdab --- /dev/null +++ b/frontend/src/components/Footer.tsx @@ -0,0 +1,157 @@ +import * as React from "react"; +import { Link } from "react-router-dom"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/hooks/use-toast"; +import { Instagram, Send, Twitter, Linkedin } from "lucide-react"; +import { api } from "@/lib/api"; // متد subscribeNewsletter را پایین توضیح داده‌ام + +export default function Footer() { + // const { toast } = useToast(); + // const [email, setEmail] = React.useState(""); + // const [loading, setLoading] = React.useState(false); + const year = new Date().getFullYear(); + + // const validateEmail = (v: string) => + // /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim()); + + // const onSubmit = async (e: React.FormEvent) => { + // e.preventDefault(); + // const em = email.trim(); + // if (!validateEmail(em)) { + // toast({ title: "ایمیل نامعتبر است", description: "لطفاً یک ایمیل صحیح وارد کنید.", variant: "destructive" }); + // return; + // } + // try { + // setLoading(true); + // const response = await api.subscribeNewsletter(em); + + // if (response.success) { + // toast({ title: "عضویت موفق", description: response.message }); + // setEmail(""); + // } else { + // toast({ title: "عضویت ناموفق", description: response.message, variant: "destructive" }); + // } + + // } catch (err: any) { + // toast({ title: "خطا", description: err?.message || "مشکلی رخ داد.", variant: "destructive" }); + // } finally { + // setLoading(false); + // } + // }; + + return ( +
+ ); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx new file mode 100644 index 0000000..3209dad --- /dev/null +++ b/frontend/src/components/Layout.tsx @@ -0,0 +1,15 @@ +import { Outlet } from 'react-router-dom'; +import Navbar from './Navbar'; +import Footer from './Footer'; +import ScrollToTop from './ScrollToTop'; + +export default function Layout() { + return ( +
+ + + +
+
+ ); +} diff --git a/frontend/src/components/Markdown.tsx b/frontend/src/components/Markdown.tsx new file mode 100644 index 0000000..521c308 --- /dev/null +++ b/frontend/src/components/Markdown.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import ReactMarkdown from 'react-markdown'; +import type { PluggableList } from 'unified'; +import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; +import rehypeSanitize from 'rehype-sanitize'; + +type MarkdownSize = 'sm' | 'base' | 'lg'; + +type MarkdownProps = { + content?: string; + allowHtml?: boolean; + className?: string; + dir?: 'rtl' | 'ltr'; + justify?: boolean; + size?: MarkdownSize; +}; + +export default function Markdown({ + content = '', + allowHtml = false, + className = '', + dir = 'rtl', + justify = false, + size = 'sm', +}: MarkdownProps) { + const rehypePlugins: PluggableList | undefined = allowHtml ? [rehypeRaw, rehypeSanitize] : undefined; + + const baseSizeClass = + size === 'sm' ? 'text-sm' : size === 'lg' ? 'text-lg' : 'text-base'; + + const hScale = + size === 'sm' + ? { h1: 'text-xl', h2: 'text-lg', h3: 'text-base', h4: 'text-base' } + : size === 'base' + ? { h1: 'text-3xl', h2: 'text-2xl', h3: 'text-xl', h4: 'text-lg' } + : { h1: 'text-4xl', h2: 'text-3xl', h3: 'text-2xl', h4: 'text-xl' }; + + const justifyStyle: React.CSSProperties | undefined = justify + ? { textAlign: 'justify', textJustify: 'inter-word' } + : undefined; + + return ( +
+

, + h2: (p) =>

, + h3: (p) =>

, + h4: (p) =>

, + p: (p) =>

, + a: (p) => , + ul: (p) =>