From 88b793ed9f676953f2c6302b83fbdff5957d7886 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Tue, 19 May 2026 20:53:08 +0330 Subject: [PATCH] initial commit --- .coveragerc | 31 + .env.sample | 60 + .env.test | 51 + .github/workflows/backend.yml | 129 ++ .gitignore | 139 ++ README.md | 45 + apps/__init__.py | 0 apps/blog/admin.py | 159 +++ apps/blog/api/__init__.py | 0 apps/blog/api/schemas.py | 87 ++ apps/blog/api/views.py | 304 +++++ apps/blog/apps.py | 6 + apps/blog/fixtures/blog.json | 672 +++++++++ apps/blog/migrations/0001_initial.py | 89 ++ apps/blog/migrations/0002_initial.py | 78 ++ apps/blog/migrations/__init__.py | 0 apps/blog/models.py | 137 ++ apps/blog/resources.py | 32 + apps/certificates/__init__.py | 1 + apps/certificates/admin.py | 24 + apps/certificates/api/__init__.py | 0 apps/certificates/api/schemas.py | 70 + apps/certificates/api/views.py | 138 ++ apps/certificates/apps.py | 6 + apps/certificates/migrations/0001_initial.py | 86 ++ .../0002_alter_usercertificate_code.py | 19 + apps/certificates/migrations/__init__.py | 1 + apps/certificates/models.py | 316 +++++ apps/communications/admin.py | 122 ++ apps/communications/api/__init__.py | 0 apps/communications/api/schemas.py | 124 ++ apps/communications/api/views.py | 329 +++++ apps/communications/apps.py | 7 + .../fixtures/communications.json | 536 ++++++++ .../communications/migrations/0001_initial.py | 78 ++ .../communications/migrations/0002_initial.py | 37 + apps/communications/migrations/__init__.py | 0 apps/communications/models.py | 142 ++ apps/communications/push_notifications.py | 194 +++ apps/communications/resources.py | 56 + apps/communications/tasks.py | 278 ++++ apps/communications/utils.py | 140 ++ apps/events/admin.py | 418 ++++++ apps/events/admin_forms.py | 25 + apps/events/api/__init__.py | 0 apps/events/api/schemas.py | 247 ++++ apps/events/api/views.py | 370 +++++ apps/events/apps.py | 6 + apps/events/fixtures/events.json | 379 ++++++ apps/events/migrations/0001_initial.py | 60 + apps/events/migrations/0002_initial.py | 27 + apps/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 + .../0013_alter_eventemaillog_kind.py | 18 + apps/events/migrations/__init__.py | 0 apps/events/models.py | 269 ++++ apps/events/resources.py | 86 ++ apps/events/tasks.py | 584 ++++++++ apps/events/tests/__init__.py | 0 apps/events/tests/integration/__init__.py | 0 apps/events/tests/integration/test_events.py | 540 ++++++++ apps/events/tests/unit/__init__.py | 0 apps/events/tests/unit/test_events.py | 1197 +++++++++++++++++ apps/gallery/admin.py | 89 ++ apps/gallery/api/__init__.py | 0 apps/gallery/api/schemas.py | 27 + apps/gallery/api/views.py | 128 ++ apps/gallery/apps.py | 6 + apps/gallery/fixtures/gallery.json | 218 +++ apps/gallery/migrations/0001_initial.py | 36 + apps/gallery/migrations/0002_initial.py | 23 + apps/gallery/migrations/__init__.py | 0 apps/gallery/models.py | 82 ++ apps/gallery/resources.py | 17 + apps/gallery/tasks.py | 23 + apps/payments/admin.py | 83 ++ apps/payments/api/__init__.py | 0 apps/payments/api/schemas.py | 35 + apps/payments/api/views.py | 240 ++++ apps/payments/apps.py | 6 + apps/payments/migrations/0001_initial.py | 64 + apps/payments/migrations/0002_initial.py | 23 + .../migrations/0003_payment_registration.py | 20 + apps/payments/migrations/__init__.py | 0 apps/payments/models.py | 122 ++ apps/payments/resources.py | 44 + apps/payments/tests/__init__.py | 0 apps/payments/tests/integration/__init__.py | 0 .../tests/integration/test_payments.py | 282 ++++ apps/payments/tests/unit/__init__.py | 0 apps/payments/tests/unit/test_payments.py | 194 +++ apps/users/admin.py | 122 ++ apps/users/api/__init__.py | 0 apps/users/api/meta.py | 15 + apps/users/api/schemas.py | 129 ++ apps/users/api/views.py | 403 ++++++ apps/users/apps.py | 9 + apps/users/fixtures/agile.json | 48 + apps/users/fixtures/users.json | 244 ++++ apps/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 | 372 +++++ .../0005_populate_major_university.py | 316 +++++ .../migrations/0006_remove_legacy_fields.py | 19 + apps/users/migrations/__init__.py | 0 apps/users/models.py | 112 ++ apps/users/resources.py | 29 + apps/users/signals.py | 27 + apps/users/tasks.py | 99 ++ apps/users/tests/__init__.py | 0 apps/users/tests/integration/__init__.py | 0 apps/users/tests/integration/test_users.py | 724 ++++++++++ apps/users/tests/unit/__init__.py | 0 apps/users/tests/unit/test_users.py | 400 ++++++ celerybeat-schedule | Bin 0 -> 18455 bytes config/__init__.py | 3 + config/api.py | 23 + config/asgi.py | 7 + config/services/celery.py | 56 + config/services/location.py | 14 + config/services/notifications.py | 12 + config/services/unfold.py | 94 ++ config/services/zarinpal.py | 10 + config/settings/base.py | 240 ++++ config/settings/development.py | 40 + config/settings/production.py | 21 + config/settings/test.py | 46 + config/urls.py | 24 + config/wsgi.py | 7 + core/__init__.py | 1 + core/admin.py | 85 ++ core/api/__init__.py | 1 + core/api/schemas.py | 13 + core/api/views.py | 15 + core/apps.py | 7 + core/authentication.py | 52 + core/choices.py | 293 ++++ core/models.py | 57 + core/templatetags/__init__.py | 0 core/templatetags/jalali.py | 23 + manage.py | 22 + requirements.txt | 71 + static/css/styles.css | 215 +++ static/img/logo.png | Bin 0 -> 144230 bytes static/js/push-notifications.js | 182 +++ static/js/scripts.js | 21 + 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 + templates/emails/event_reminder.html | 25 + templates/emails/newsletter_confirmation.html | 108 ++ templates/emails/password_reset_email.html | 132 ++ templates/emails/skyroom_credentials.html | 32 + templates/emails/verification_email.html | 61 + templates/emails/verification_success.html | 106 ++ templates/forms/admin_announcement.html | 26 + 169 files changed, 16763 insertions(+) create mode 100644 .coveragerc create mode 100644 .env.sample create mode 100644 .env.test create mode 100644 .github/workflows/backend.yml create mode 100644 .gitignore create mode 100644 README.md create mode 100644 apps/__init__.py create mode 100644 apps/blog/admin.py create mode 100644 apps/blog/api/__init__.py create mode 100644 apps/blog/api/schemas.py create mode 100644 apps/blog/api/views.py create mode 100644 apps/blog/apps.py create mode 100644 apps/blog/fixtures/blog.json create mode 100644 apps/blog/migrations/0001_initial.py create mode 100644 apps/blog/migrations/0002_initial.py create mode 100644 apps/blog/migrations/__init__.py create mode 100644 apps/blog/models.py create mode 100644 apps/blog/resources.py create mode 100644 apps/certificates/__init__.py create mode 100644 apps/certificates/admin.py create mode 100644 apps/certificates/api/__init__.py create mode 100644 apps/certificates/api/schemas.py create mode 100644 apps/certificates/api/views.py create mode 100644 apps/certificates/apps.py create mode 100644 apps/certificates/migrations/0001_initial.py create mode 100644 apps/certificates/migrations/0002_alter_usercertificate_code.py create mode 100644 apps/certificates/migrations/__init__.py create mode 100644 apps/certificates/models.py create mode 100644 apps/communications/admin.py create mode 100644 apps/communications/api/__init__.py create mode 100644 apps/communications/api/schemas.py create mode 100644 apps/communications/api/views.py create mode 100644 apps/communications/apps.py create mode 100644 apps/communications/fixtures/communications.json create mode 100644 apps/communications/migrations/0001_initial.py create mode 100644 apps/communications/migrations/0002_initial.py create mode 100644 apps/communications/migrations/__init__.py create mode 100644 apps/communications/models.py create mode 100644 apps/communications/push_notifications.py create mode 100644 apps/communications/resources.py create mode 100644 apps/communications/tasks.py create mode 100644 apps/communications/utils.py create mode 100644 apps/events/admin.py create mode 100644 apps/events/admin_forms.py create mode 100644 apps/events/api/__init__.py create mode 100644 apps/events/api/schemas.py create mode 100644 apps/events/api/views.py create mode 100644 apps/events/apps.py create mode 100644 apps/events/fixtures/events.json create mode 100644 apps/events/migrations/0001_initial.py create mode 100644 apps/events/migrations/0002_initial.py create mode 100644 apps/events/migrations/0003_initial.py create mode 100644 apps/events/migrations/0004_event_registration_success_markdown.py create mode 100644 apps/events/migrations/0005_registration_cancellation_email_sent_at_and_more.py create mode 100644 apps/events/migrations/0006_alter_event_options_alter_registration_options_and_more.py create mode 100644 apps/events/migrations/0007_eventemaillog_deleted_at_eventemaillog_is_deleted_and_more.py create mode 100644 apps/events/migrations/0008_alter_eventemaillog_kind.py create mode 100644 apps/events/migrations/0009_registration_discount_amount_and_more.py create mode 100644 apps/events/migrations/0010_backfill_registration_discounts.py create mode 100644 apps/events/migrations/0011_eventemaillog_context_hash.py create mode 100644 apps/events/migrations/0012_alter_eventemaillog_kind.py create mode 100644 apps/events/migrations/0013_alter_eventemaillog_kind.py create mode 100644 apps/events/migrations/__init__.py create mode 100644 apps/events/models.py create mode 100644 apps/events/resources.py create mode 100644 apps/events/tasks.py create mode 100644 apps/events/tests/__init__.py create mode 100644 apps/events/tests/integration/__init__.py create mode 100644 apps/events/tests/integration/test_events.py create mode 100644 apps/events/tests/unit/__init__.py create mode 100644 apps/events/tests/unit/test_events.py create mode 100644 apps/gallery/admin.py create mode 100644 apps/gallery/api/__init__.py create mode 100644 apps/gallery/api/schemas.py create mode 100644 apps/gallery/api/views.py create mode 100644 apps/gallery/apps.py create mode 100644 apps/gallery/fixtures/gallery.json create mode 100644 apps/gallery/migrations/0001_initial.py create mode 100644 apps/gallery/migrations/0002_initial.py create mode 100644 apps/gallery/migrations/__init__.py create mode 100644 apps/gallery/models.py create mode 100644 apps/gallery/resources.py create mode 100644 apps/gallery/tasks.py create mode 100644 apps/payments/admin.py create mode 100644 apps/payments/api/__init__.py create mode 100644 apps/payments/api/schemas.py create mode 100644 apps/payments/api/views.py create mode 100644 apps/payments/apps.py create mode 100644 apps/payments/migrations/0001_initial.py create mode 100644 apps/payments/migrations/0002_initial.py create mode 100644 apps/payments/migrations/0003_payment_registration.py create mode 100644 apps/payments/migrations/__init__.py create mode 100644 apps/payments/models.py create mode 100644 apps/payments/resources.py create mode 100644 apps/payments/tests/__init__.py create mode 100644 apps/payments/tests/integration/__init__.py create mode 100644 apps/payments/tests/integration/test_payments.py create mode 100644 apps/payments/tests/unit/__init__.py create mode 100644 apps/payments/tests/unit/test_payments.py create mode 100644 apps/users/admin.py create mode 100644 apps/users/api/__init__.py create mode 100644 apps/users/api/meta.py create mode 100644 apps/users/api/schemas.py create mode 100644 apps/users/api/views.py create mode 100644 apps/users/apps.py create mode 100644 apps/users/fixtures/agile.json create mode 100644 apps/users/fixtures/users.json create mode 100644 apps/users/migrations/0001_initial.py create mode 100644 apps/users/migrations/0002_alter_user_university.py create mode 100644 apps/users/migrations/0003_alter_user_university.py create mode 100644 apps/users/migrations/0004_major_university_models.py create mode 100644 apps/users/migrations/0005_populate_major_university.py create mode 100644 apps/users/migrations/0006_remove_legacy_fields.py create mode 100644 apps/users/migrations/__init__.py create mode 100644 apps/users/models.py create mode 100644 apps/users/resources.py create mode 100644 apps/users/signals.py create mode 100644 apps/users/tasks.py create mode 100644 apps/users/tests/__init__.py create mode 100644 apps/users/tests/integration/__init__.py create mode 100644 apps/users/tests/integration/test_users.py create mode 100644 apps/users/tests/unit/__init__.py create mode 100644 apps/users/tests/unit/test_users.py create mode 100644 celerybeat-schedule create mode 100644 config/__init__.py create mode 100644 config/api.py create mode 100644 config/asgi.py create mode 100644 config/services/celery.py create mode 100644 config/services/location.py create mode 100644 config/services/notifications.py create mode 100644 config/services/unfold.py create mode 100644 config/services/zarinpal.py create mode 100644 config/settings/base.py create mode 100644 config/settings/development.py create mode 100644 config/settings/production.py create mode 100644 config/settings/test.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 core/__init__.py create mode 100644 core/admin.py create mode 100644 core/api/__init__.py create mode 100644 core/api/schemas.py create mode 100644 core/api/views.py create mode 100644 core/apps.py create mode 100644 core/authentication.py create mode 100644 core/choices.py create mode 100644 core/models.py create mode 100644 core/templatetags/__init__.py create mode 100644 core/templatetags/jalali.py create mode 100644 manage.py create mode 100644 requirements.txt create mode 100644 static/css/styles.css create mode 100644 static/img/logo.png create mode 100644 static/js/push-notifications.js create mode 100644 static/js/scripts.js create mode 100644 static/js/sw.js create mode 100644 templates/emails/announcement_email.html create mode 100644 templates/emails/event_announcement.html create mode 100644 templates/emails/event_invite_non_registered.html create mode 100644 templates/emails/event_invite_non_registered.txt create mode 100644 templates/emails/event_registration_cancellation.html create mode 100644 templates/emails/event_registration_confirmation.html create mode 100644 templates/emails/event_reminder.html create mode 100644 templates/emails/newsletter_confirmation.html create mode 100644 templates/emails/password_reset_email.html create mode 100644 templates/emails/skyroom_credentials.html create mode 100644 templates/emails/verification_email.html create mode 100644 templates/emails/verification_success.html create mode 100644 templates/forms/admin_announcement.html diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..2cad10f --- /dev/null +++ b/.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/.env.sample b/.env.sample new file mode 100644 index 0000000..cff6806 --- /dev/null +++ b/.env.sample @@ -0,0 +1,60 @@ +# Gunicorn +GUNICORN_WORKERS=3 +GUNICORN_THREADS=2 +GUNICORN_TIMEOUT=120 + +# Django +DJANGO_SETTINGS_MODULE=config.settings.production +SECRET_KEY=replace-me +DEBUG=False +ALLOWED_HOSTS=api-host.example +DJANGO_HOST=https://api-host.example + +# Database +DB_ENGINE=django.db.backends.postgresql +DB_NAME=app +DB_USER=app +DB_PASSWORD=change-me +DB_HOST=db +DB_PORT=5432 + +# Redis / Celery +REDIS_PASSWORD=change-me +REDIS_URL=redis://:change-me@redis:6379/0 +CELERY_BROKER_URL=redis://:change-me@redis:6379/0 +CELERY_RESULT_BACKEND=redis://:change-me@redis:6379/1 + +# Email +EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend +EMAIL_HOST=smtp.example.com +EMAIL_PORT=587 +EMAIL_USE_TLS=True +EMAIL_HOST_USER=smtp-user +EMAIL_HOST_PASSWORD=smtp-password +DEFAULT_FROM_EMAIL=noreply@example.com + +# JWT +JWT_SECRET_KEY=replace-me +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_LIFETIME=3600 +JWT_REFRESH_TOKEN_LIFETIME=86400 + +# Frontend integration +CORS_ALLOWED_ORIGINS=https://frontend-host.example +FRONTEND_ROOT=https://frontend-host.example +FRONTEND_PASSWORD_RESET_PAGE=https://frontend-host.example/reset-password +FRONTEND_CALLBACK_URL=https://frontend-host.example/payments/result + +# ZarinPal +ZARINPAL_MERCHANT_ID=merchant-id +ZARINPAL_USE_SANDBOX=False +ZARINPAL_CALLBACK_URL=https://api-host.example/api/payments/callback + +# 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..201dfc7 --- /dev/null +++ b/.env.test @@ -0,0 +1,51 @@ +# Django +DJANGO_SETTINGS_MODULE=config.settings.test +SECRET_KEY=test-secret-key +DEBUG=False +ALLOWED_HOSTS=localhost,127.0.0.1,testserver +DJANGO_HOST=http://localhost:8000 + +# Gunicorn +GUNICORN_WORKERS=2 +GUNICORN_THREADS=2 +GUNICORN_TIMEOUT=120 + +# Test database +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 / Celery +REDIS_PASSWORD= +REDIS_URL=redis://localhost:6379/0 +CELERY_BROKER_URL=redis://localhost:6379/0 +CELERY_RESULT_BACKEND=redis://localhost:6379/1 + +# Email +EMAIL_BACKEND=django.core.mail.backends.locmem.EmailBackend +EMAIL_HOST=localhost +EMAIL_PORT=587 +EMAIL_USE_TLS=False +EMAIL_HOST_USER= +EMAIL_HOST_PASSWORD= +DEFAULT_FROM_EMAIL=noreply@example.com + +# JWT +JWT_SECRET_KEY=test-jwt-key +JWT_ALGORITHM=HS256 +JWT_ACCESS_TOKEN_LIFETIME=3600 +JWT_REFRESH_TOKEN_LIFETIME=86400 + +# Frontend integration +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 + +# ZarinPal +ZARINPAL_MERCHANT_ID=sandbox-merchant +ZARINPAL_USE_SANDBOX=True +ZARINPAL_CALLBACK_URL=http://localhost:8000/api/payments/callback diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000..b8656de --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,129 @@ +name: Backend CI/CD + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +jobs: + test: + 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 + 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/api/payments/callback + PYTHON_VERSION: "3.12" + 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: requirements.txt + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install coverage + + - name: Prepare environment file + run: cp .env.test .env + + - name: Run migrations + run: python manage.py migrate --noinput + + - name: Run tests with coverage + run: | + coverage run --rcfile=.coveragerc manage.py test --settings=config.settings.test --verbosity 2 + coverage report -m + + deploy: + runs-on: ubuntu-latest + needs: test + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + timeout-minutes: 30 + steps: + - name: Deploy backend services + 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 }}/backend/guilan-ace-backend" + git fetch --prune origin + git checkout "${{ vars.BACKEND_BRANCH || 'main' }}" + git pull --ff-only origin "${{ vars.BACKEND_BRANCH || 'main' }}" + cd "${{ secrets.DEPLOY_PATH }}" + docker compose up -d --build web worker beat + docker image prune -f diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1601d03 --- /dev/null +++ b/.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/README.md b/README.md new file mode 100644 index 0000000..073b6c8 --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# Guilan ACE Backend + +## Stack +- Django 5 with Ninja API routers +- PostgreSQL, Redis, Celery, Gunicorn +- Prometheus instrumentation through `django-prometheus` + +## Layout +```text +guilan-ace-backend/ + apps/ + users/ + blog/ + gallery/ + events/ + communications/ + payments/ + certificates/ + core/ + config/ + static/ + templates/ + manage.py +``` + +## Local setup +```bash +python -m venv .venv +.venv\\Scripts\\activate +pip install -r requirements.txt +cp .env.sample .env +python manage.py migrate +python manage.py runserver +``` + +## Tests +```bash +python manage.py test --settings=config.settings.test +``` + +## Notes +- Domain routers live under `apps//api`. +- Shared auth helpers live in `core/authentication.py`. +- Shared base models, admin helpers, choices, and template tags live under `core/`. + diff --git a/apps/__init__.py b/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/admin.py b/apps/blog/admin.py new file mode 100644 index 0000000..84647a2 --- /dev/null +++ b/apps/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 apps.blog.models import Category, Tag, Post, Comment, Like +from apps.blog.resources import PostResource, CategoryResource +from core.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/apps/blog/api/__init__.py b/apps/blog/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/api/schemas.py b/apps/blog/api/schemas.py new file mode 100644 index 0000000..0c1c44b --- /dev/null +++ b/apps/blog/api/schemas.py @@ -0,0 +1,87 @@ +"""Blog API schemas.""" + +from ninja import Schema, ModelSchema +from typing import Optional, List +from datetime import datetime + +from apps.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/apps/blog/api/views.py b/apps/blog/api/views.py new file mode 100644 index 0000000..0b7152a --- /dev/null +++ b/apps/blog/api/views.py @@ -0,0 +1,304 @@ +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 apps.users.models import User +from apps.blog.api.schemas import ( + CategorySchema, + CommentCreateSchema, + CommentSchema, + PostCreateSchema, + PostDetailSchema, + PostListSchema, + TagSchema, +) +from apps.blog.models import Post, Category, Tag, Comment, Like +from core.api.schemas import ErrorSchema, MessageSchema +from core.authentication import jwt_auth + +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/apps/blog/apps.py b/apps/blog/apps.py new file mode 100644 index 0000000..7c0f5c2 --- /dev/null +++ b/apps/blog/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class BlogConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.blog" diff --git a/apps/blog/fixtures/blog.json b/apps/blog/fixtures/blog.json new file mode 100644 index 0000000..9c6ecf5 --- /dev/null +++ b/apps/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/apps/blog/migrations/0001_initial.py b/apps/blog/migrations/0001_initial.py new file mode 100644 index 0000000..2f24b9f --- /dev/null +++ b/apps/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/apps/blog/migrations/0002_initial.py b/apps/blog/migrations/0002_initial.py new file mode 100644 index 0000000..5a1655b --- /dev/null +++ b/apps/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/apps/blog/migrations/__init__.py b/apps/blog/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/blog/models.py b/apps/blog/models.py new file mode 100644 index 0000000..ab9a5ea --- /dev/null +++ b/apps/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 core.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/apps/blog/resources.py b/apps/blog/resources.py new file mode 100644 index 0000000..6c88ddd --- /dev/null +++ b/apps/blog/resources.py @@ -0,0 +1,32 @@ +from import_export import resources, fields +from import_export.widgets import ForeignKeyWidget, ManyToManyWidget + +from apps.users.models import User +from apps.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/apps/certificates/__init__.py b/apps/certificates/__init__.py new file mode 100644 index 0000000..f3b0d32 --- /dev/null +++ b/apps/certificates/__init__.py @@ -0,0 +1 @@ +"""""" diff --git a/apps/certificates/admin.py b/apps/certificates/admin.py new file mode 100644 index 0000000..a5c57ce --- /dev/null +++ b/apps/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/apps/certificates/api/__init__.py b/apps/certificates/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/certificates/api/schemas.py b/apps/certificates/api/schemas.py new file mode 100644 index 0000000..ab1e91a --- /dev/null +++ b/apps/certificates/api/schemas.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/apps/certificates/api/views.py b/apps/certificates/api/views.py new file mode 100644 index 0000000..bb9881d --- /dev/null +++ b/apps/certificates/api/views.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 core.authentication import jwt_auth +from apps.certificates.api.schemas import ( + CertificateTemplateOut, + CertificateGenerationPayload, + CertificateGenerationResponse, + CertificateVerificationOut, + SkillSchema, + UserCertificateOut, +) +from apps.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/apps/certificates/apps.py b/apps/certificates/apps.py new file mode 100644 index 0000000..a2d66c7 --- /dev/null +++ b/apps/certificates/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class CertificatesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.certificates" diff --git a/apps/certificates/migrations/0001_initial.py b/apps/certificates/migrations/0001_initial.py new file mode 100644 index 0000000..a04daa5 --- /dev/null +++ b/apps/certificates/migrations/0001_initial.py @@ -0,0 +1,86 @@ +# Generated by Django 4.2.13 on 2025-11-18 09:47 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import uuid + + +SHORT_CERTIFICATE_CODE_LENGTH = 10 + + +def generate_certificate_code(): + return uuid.uuid4().hex[:SHORT_CERTIFICATE_CODE_LENGTH] + + +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=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/apps/certificates/migrations/0002_alter_usercertificate_code.py b/apps/certificates/migrations/0002_alter_usercertificate_code.py new file mode 100644 index 0000000..9f25dbc --- /dev/null +++ b/apps/certificates/migrations/0002_alter_usercertificate_code.py @@ -0,0 +1,19 @@ +# Generated by Django 5.2.5 on 2026-05-19 14:07 + +import apps.certificates.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('certificates', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='usercertificate', + name='code', + field=models.CharField(default=apps.certificates.models._generate_certificate_code, editable=False, max_length=10, unique=True), + ), + ] diff --git a/apps/certificates/migrations/__init__.py b/apps/certificates/migrations/__init__.py new file mode 100644 index 0000000..f3b0d32 --- /dev/null +++ b/apps/certificates/migrations/__init__.py @@ -0,0 +1 @@ +"""""" diff --git a/apps/certificates/models.py b/apps/certificates/models.py new file mode 100644 index 0000000..23aa872 --- /dev/null +++ b/apps/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 apps.events.models import Registration +from apps.users.models import User +from core.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/apps/communications/admin.py b/apps/communications/admin.py new file mode 100644 index 0000000..c27d581 --- /dev/null +++ b/apps/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 core.admin import SoftDeleteListFilter, BaseModelAdmin +from apps.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/apps/communications/api/__init__.py b/apps/communications/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/communications/api/schemas.py b/apps/communications/api/schemas.py new file mode 100644 index 0000000..a5134e9 --- /dev/null +++ b/apps/communications/api/schemas.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 apps.blog.api.schemas import AuthorSchema +from apps.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/apps/communications/api/views.py b/apps/communications/api/views.py new file mode 100644 index 0000000..edc64b6 --- /dev/null +++ b/apps/communications/api/views.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 apps.communications.models import ( + Announcement, NewsletterSubscription, PushNotificationDevice, + AnnouncementType, AnnouncementPriority +) +from apps.communications.utils import ( + send_announcement_email, send_newsletter_confirmation, + get_announcement_recipients +) +from apps.communications.push_notifications import push_service +from apps.communications.api.schemas import ( + AnnouncementSchema, AnnouncementListSchema, AnnouncementCreateSchema, AnnouncementUpdateSchema, + NewsletterSubscriptionSchema, NewsletterSubscribeSchema, NewsletterUnsubscribeSchema, + PushDeviceSchema, PushDeviceCreateSchema, PushDeviceUpdateSchema, + PushNotificationSchema, MessageResponseSchema, + AnnouncementStatsSchema, NewsletterStatsSchema +) +from core.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/apps/communications/apps.py b/apps/communications/apps.py new file mode 100644 index 0000000..fe8e20c --- /dev/null +++ b/apps/communications/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CommunicationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.communications" + verbose_name = "Communications" diff --git a/apps/communications/fixtures/communications.json b/apps/communications/fixtures/communications.json new file mode 100644 index 0000000..b93216d --- /dev/null +++ b/apps/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/apps/communications/migrations/0001_initial.py b/apps/communications/migrations/0001_initial.py new file mode 100644 index 0000000..51df34d --- /dev/null +++ b/apps/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/apps/communications/migrations/0002_initial.py b/apps/communications/migrations/0002_initial.py new file mode 100644 index 0000000..30b273d --- /dev/null +++ b/apps/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/apps/communications/migrations/__init__.py b/apps/communications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/communications/models.py b/apps/communications/models.py new file mode 100644 index 0000000..8e862f8 --- /dev/null +++ b/apps/communications/models.py @@ -0,0 +1,142 @@ +from django.db import models +from django.contrib.auth import get_user_model + +from core.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/apps/communications/push_notifications.py b/apps/communications/push_notifications.py new file mode 100644 index 0000000..14b13c0 --- /dev/null +++ b/apps/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 apps.communications.models import PushNotificationDevice +from apps.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/apps/communications/resources.py b/apps/communications/resources.py new file mode 100644 index 0000000..9059ec4 --- /dev/null +++ b/apps/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 apps.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/apps/communications/tasks.py b/apps/communications/tasks.py new file mode 100644 index 0000000..c9ca18c --- /dev/null +++ b/apps/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 apps.events.models import Event, Registration +from apps.communications.models import Announcement, NewsletterSubscription +from apps.communications.utils import send_announcement_email, send_event_reminder, get_announcement_recipients +from apps.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/apps/communications/utils.py b/apps/communications/utils.py new file mode 100644 index 0000000..719c317 --- /dev/null +++ b/apps/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 apps.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/apps/events/admin.py b/apps/events/admin.py new file mode 100644 index 0000000..aec15c8 --- /dev/null +++ b/apps/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 core.templatetags.jalali import jdate +from unfold.decorators import action as unfold_action + +from core.admin import SoftDeleteListFilter, BaseModelAdmin +from apps.events.models import Event, Registration, EventEmailLog +from apps.events.resources import EventResource, RegistrationResource +from apps.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 apps.events.admin_forms import AnnouncementForm +from apps.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 apps.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/apps/events/admin_forms.py b/apps/events/admin_forms.py new file mode 100644 index 0000000..a78a60f --- /dev/null +++ b/apps/events/admin_forms.py @@ -0,0 +1,25 @@ +from django import forms + +from unfold.widgets import UnfoldAdminTextInputWidget, UnfoldAdminTextareaWidget + +from apps.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/apps/events/api/__init__.py b/apps/events/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/events/api/schemas.py b/apps/events/api/schemas.py new file mode 100644 index 0000000..9dc60a9 --- /dev/null +++ b/apps/events/api/schemas.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 apps.blog.api.schemas import AuthorSchema +from apps.events.models import Event, Registration +from apps.gallery.models import Gallery +from apps.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/apps/events/api/views.py b/apps/events/api/views.py new file mode 100644 index 0000000..4e0fe18 --- /dev/null +++ b/apps/events/api/views.py @@ -0,0 +1,370 @@ +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 apps.events.api.schemas import ( + EventAdminDetailSchema, + EventBriefSchema, + EventCreateSchema, + EventListSchema, + EventSchema, + EventUpdateSchema, + MyEventRegistrationOut, + PaginatedRegistrationSchema, + RegisterationDetailSchema, + RegistrationCreateSchema, + RegistrationSchema, + RegistrationStatusOut, + RegistrationStatusUpdateSchema, +) +from core.authentication import jwt_auth +from apps.events.models import Event, Registration +from apps.payments.models import DiscountCode +from core.api.schemas import ErrorSchema, MessageSchema + +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/apps/events/apps.py b/apps/events/apps.py new file mode 100644 index 0000000..cec9b91 --- /dev/null +++ b/apps/events/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class EventsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.events" diff --git a/apps/events/fixtures/events.json b/apps/events/fixtures/events.json new file mode 100644 index 0000000..cbd39a1 --- /dev/null +++ b/apps/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/apps/events/migrations/0001_initial.py b/apps/events/migrations/0001_initial.py new file mode 100644 index 0000000..76afc34 --- /dev/null +++ b/apps/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/apps/events/migrations/0002_initial.py b/apps/events/migrations/0002_initial.py new file mode 100644 index 0000000..3872487 --- /dev/null +++ b/apps/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/apps/events/migrations/0003_initial.py b/apps/events/migrations/0003_initial.py new file mode 100644 index 0000000..60e4b53 --- /dev/null +++ b/apps/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/apps/events/migrations/0004_event_registration_success_markdown.py b/apps/events/migrations/0004_event_registration_success_markdown.py new file mode 100644 index 0000000..abf179c --- /dev/null +++ b/apps/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/apps/events/migrations/0005_registration_cancellation_email_sent_at_and_more.py b/apps/events/migrations/0005_registration_cancellation_email_sent_at_and_more.py new file mode 100644 index 0000000..aac9926 --- /dev/null +++ b/apps/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/apps/events/migrations/0006_alter_event_options_alter_registration_options_and_more.py b/apps/events/migrations/0006_alter_event_options_alter_registration_options_and_more.py new file mode 100644 index 0000000..66a3a0f --- /dev/null +++ b/apps/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/apps/events/migrations/0007_eventemaillog_deleted_at_eventemaillog_is_deleted_and_more.py b/apps/events/migrations/0007_eventemaillog_deleted_at_eventemaillog_is_deleted_and_more.py new file mode 100644 index 0000000..f783492 --- /dev/null +++ b/apps/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/apps/events/migrations/0008_alter_eventemaillog_kind.py b/apps/events/migrations/0008_alter_eventemaillog_kind.py new file mode 100644 index 0000000..16dbab2 --- /dev/null +++ b/apps/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/apps/events/migrations/0009_registration_discount_amount_and_more.py b/apps/events/migrations/0009_registration_discount_amount_and_more.py new file mode 100644 index 0000000..7c3d893 --- /dev/null +++ b/apps/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/apps/events/migrations/0010_backfill_registration_discounts.py b/apps/events/migrations/0010_backfill_registration_discounts.py new file mode 100644 index 0000000..41e550d --- /dev/null +++ b/apps/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/apps/events/migrations/0011_eventemaillog_context_hash.py b/apps/events/migrations/0011_eventemaillog_context_hash.py new file mode 100644 index 0000000..3597e9a --- /dev/null +++ b/apps/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/apps/events/migrations/0012_alter_eventemaillog_kind.py b/apps/events/migrations/0012_alter_eventemaillog_kind.py new file mode 100644 index 0000000..622b25c --- /dev/null +++ b/apps/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/apps/events/migrations/0013_alter_eventemaillog_kind.py b/apps/events/migrations/0013_alter_eventemaillog_kind.py new file mode 100644 index 0000000..6adb807 --- /dev/null +++ b/apps/events/migrations/0013_alter_eventemaillog_kind.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.5 on 2026-05-19 14:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('events', '0012_alter_eventemaillog_kind'), + ] + + 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'), ('send_event_reminder', 'Event reminder')], max_length=64), + ), + ] diff --git a/apps/events/migrations/__init__.py b/apps/events/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/events/models.py b/apps/events/models.py new file mode 100644 index 0000000..11cef0f --- /dev/null +++ b/apps/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 core.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 apps.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 apps.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 apps.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/apps/events/resources.py b/apps/events/resources.py new file mode 100644 index 0000000..d819ca4 --- /dev/null +++ b/apps/events/resources.py @@ -0,0 +1,86 @@ +from import_export import resources, fields +from import_export.widgets import ForeignKeyWidget, ManyToManyWidget + +from apps.events.models import Event, Registration +from apps.users.models import User +from apps.gallery.models import Gallery +from apps.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/apps/events/tasks.py b/apps/events/tasks.py new file mode 100644 index 0000000..1e69c8e --- /dev/null +++ b/apps/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 apps.users.models import User +from apps.events.models import Event, Registration, EventEmailLog +from core.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/apps/events/tests/__init__.py b/apps/events/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/events/tests/integration/__init__.py b/apps/events/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/events/tests/integration/test_events.py b/apps/events/tests/integration/test_events.py new file mode 100644 index 0000000..6a33ed3 --- /dev/null +++ b/apps/events/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 core.authentication import create_jwt_token +from apps.events.api.schemas import ( + EventSchema, + EventGallerySchema, + EventListSchema, + RegistrationSchema, + PaymentAdminSchema, + EventAdminDetailSchema, +) +from apps.events.api.views import list_events +from apps.events.models import Event, Registration +from apps.gallery.models import Gallery +from apps.payments.models import DiscountCode +from apps.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/apps/events/tests/unit/__init__.py b/apps/events/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/events/tests/unit/test_events.py b/apps/events/tests/unit/test_events.py new file mode 100644 index 0000000..df515a5 --- /dev/null +++ b/apps/events/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 apps.events.admin import EventAdmin, EventEmailLogAdmin, RegistrationAdmin +from apps.events.admin_forms import AnnouncementForm +from apps.events.models import Event, EventEmailLog, Registration +from apps.events.resources import RegistrationResource +from apps.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 apps.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("apps.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("apps.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("apps.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("apps.events.tasks.EmailMultiAlternatives") + @mock.patch("apps.events.tasks.render_to_string", return_value="

ok

") + @mock.patch("apps.events.tasks.strip_tags", side_effect=lambda html: "ok") + @mock.patch("apps.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("apps.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("apps.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("apps.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("apps.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("apps.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("apps.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("apps.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("apps.events.admin.queue_event_announcement") as mock_queue, \ + mock.patch("apps.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("apps.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("apps.events.admin.render_to_string", return_value="

ok

"), \ + mock.patch("apps.events.admin._send_html_email") as mock_send, \ + mock.patch("apps.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("apps.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("apps.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("apps.events.tasks.Registration.objects", manager), \ + mock.patch("apps.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("apps.events.tasks.Registration.objects", manager), \ + mock.patch("apps.events.tasks.EmailMultiAlternatives", mock_email_class), \ + mock.patch("apps.events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("apps.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("apps.events.tasks.Registration.objects", manager), \ + mock.patch("apps.events.tasks.EmailMultiAlternatives", mock.MagicMock(return_value=email_instance)), \ + mock.patch("apps.events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("apps.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("apps.events.tasks.Event.objects.get", return_value=event), \ + mock.patch("apps.events.tasks._event_recipients", return_value=regs), \ + mock.patch("apps.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("apps.events.tasks.Event.objects.get", return_value=event), \ + mock.patch("apps.events.tasks._event_recipients", return_value=DummyQS([1, 2])), \ + mock.patch("apps.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("apps.events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("apps.events.tasks.EventEmailLog.claim", return_value=(log, False)), \ + mock.patch("apps.events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("apps.events.tasks.strip_tags", return_value="ok"), \ + mock.patch("apps.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("apps.events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("apps.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("apps.events.tasks.Event.objects.get", return_value=event), \ + mock.patch("apps.events.tasks.User.objects.all", return_value=DummyUserQS([1])), \ + mock.patch("apps.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("apps.events.tasks.Event.objects.get", return_value=self.create_event()), \ + mock.patch("apps.events.tasks.User.objects.get", return_value=self.create_user()), \ + mock.patch("apps.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("apps.events.tasks.Event.objects.get", return_value=self.create_event()), \ + mock.patch("apps.events.tasks.User.objects.get", return_value=target_user), \ + mock.patch("apps.events.tasks.EventEmailLog.claim", return_value=(mock.MagicMock(), False)), \ + mock.patch("apps.events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("apps.events.tasks._build_email_context", return_value="ctx"), \ + mock.patch("apps.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("apps.events.tasks.Event.objects.get", return_value=event), \ + mock.patch("apps.events.tasks._event_recipients", return_value=DummyRegQS([1])), \ + mock.patch("apps.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("apps.events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("apps.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("apps.events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("apps.events.tasks.EventEmailLog.claim", return_value=(mock.MagicMock(), False)), \ + mock.patch("apps.events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("apps.events.tasks.strip_tags", return_value="ok"), \ + mock.patch("apps.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("apps.events.tasks.Registration.objects", manager), \ + mock.patch("apps.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("apps.events.tasks.Registration.objects", manager), \ + mock.patch("apps.events.tasks.EmailMultiAlternatives", mock_email_class), \ + mock.patch("apps.events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("apps.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("apps.events.tasks.Registration.objects", manager), \ + mock.patch("apps.events.tasks.EmailMultiAlternatives", mock.MagicMock(return_value=mock.MagicMock(send=mock.Mock(side_effect=RuntimeError("boom"))))), \ + mock.patch("apps.events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("apps.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("apps.events.tasks.Event.objects.get", return_value=event), \ + mock.patch("apps.events.tasks._event_recipients", return_value=regs), \ + mock.patch("apps.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("apps.events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("apps.events.tasks.EventEmailLog.claim", return_value=(log, False)), \ + mock.patch("apps.events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("apps.events.tasks.strip_tags", return_value="ok"), \ + mock.patch("apps.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("apps.events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("apps.events.tasks.EventEmailLog.claim", return_value=(log, False)), \ + mock.patch("apps.events.tasks.render_to_string", side_effect=SoftTimeLimitExceeded("timeout")), \ + mock.patch("apps.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("apps.events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("apps.events.tasks.EventEmailLog.claim", return_value=(log, False)), \ + mock.patch("apps.events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("apps.events.tasks.strip_tags", return_value="ok"), \ + mock.patch("apps.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("apps.events.tasks.Event.objects.get", return_value=event), \ + mock.patch("apps.events.tasks.User.objects.get", return_value=user), \ + mock.patch("apps.events.tasks.EventEmailLog.claim", return_value=(log, False)), \ + mock.patch("apps.events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("apps.events.tasks._build_email_context", return_value="ctx"), \ + mock.patch("apps.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("apps.events.tasks.Registration.objects.select_related") as mock_select, \ + mock.patch("apps.events.tasks.EventEmailLog.claim", return_value=(log, False)), \ + mock.patch("apps.events.tasks.render_to_string", return_value="

ok

"), \ + mock.patch("apps.events.tasks.strip_tags", return_value="ok"), \ + mock.patch("apps.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("apps.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/apps/gallery/admin.py b/apps/gallery/admin.py new file mode 100644 index 0000000..7a2c304 --- /dev/null +++ b/apps/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 apps.gallery.models import Gallery +from apps.gallery.resources import GalleryResource +from core.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/apps/gallery/api/__init__.py b/apps/gallery/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gallery/api/schemas.py b/apps/gallery/api/schemas.py new file mode 100644 index 0000000..acbde79 --- /dev/null +++ b/apps/gallery/api/schemas.py @@ -0,0 +1,27 @@ +"""Schemas for gallery resources.""" + +from ninja import Schema, ModelSchema +from typing import Optional + +from apps.blog.api.schemas import AuthorSchema +from apps.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/apps/gallery/api/views.py b/apps/gallery/api/views.py new file mode 100644 index 0000000..96d741e --- /dev/null +++ b/apps/gallery/api/views.py @@ -0,0 +1,128 @@ +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 apps.gallery.api.schemas import GalleryCreateSchema, GallerySchema +from apps.gallery.models import Gallery +from apps.gallery.tasks import process_uploaded_image +from core.api.schemas import ErrorSchema, MessageSchema +from core.authentication import jwt_auth + +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/apps/gallery/apps.py b/apps/gallery/apps.py new file mode 100644 index 0000000..377b434 --- /dev/null +++ b/apps/gallery/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GalleryConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.gallery" diff --git a/apps/gallery/fixtures/gallery.json b/apps/gallery/fixtures/gallery.json new file mode 100644 index 0000000..d34cf14 --- /dev/null +++ b/apps/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/apps/gallery/migrations/0001_initial.py b/apps/gallery/migrations/0001_initial.py new file mode 100644 index 0000000..f8bbe49 --- /dev/null +++ b/apps/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/apps/gallery/migrations/0002_initial.py b/apps/gallery/migrations/0002_initial.py new file mode 100644 index 0000000..3683b96 --- /dev/null +++ b/apps/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/apps/gallery/migrations/__init__.py b/apps/gallery/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gallery/models.py b/apps/gallery/models.py new file mode 100644 index 0000000..b55442a --- /dev/null +++ b/apps/gallery/models.py @@ -0,0 +1,82 @@ +from django.db import models +from django.conf import settings + +from PIL import Image + +from core.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/apps/gallery/resources.py b/apps/gallery/resources.py new file mode 100644 index 0000000..12fb588 --- /dev/null +++ b/apps/gallery/resources.py @@ -0,0 +1,17 @@ +from import_export import resources, fields +from import_export.widgets import ForeignKeyWidget + +from apps.gallery.models import Gallery +from apps.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/apps/gallery/tasks.py b/apps/gallery/tasks.py new file mode 100644 index 0000000..0556633 --- /dev/null +++ b/apps/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/apps/payments/admin.py b/apps/payments/admin.py new file mode 100644 index 0000000..a56e4a8 --- /dev/null +++ b/apps/payments/admin.py @@ -0,0 +1,83 @@ +from django.contrib import admin + +from import_export.admin import ImportExportModelAdmin + +from core.admin import SoftDeleteListFilter, BaseModelAdmin +from apps.payments.resources import DiscountResource, PaymentResource +from apps.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/apps/payments/api/__init__.py b/apps/payments/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/payments/api/schemas.py b/apps/payments/api/schemas.py new file mode 100644 index 0000000..7b7b274 --- /dev/null +++ b/apps/payments/api/schemas.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/apps/payments/api/views.py b/apps/payments/api/views.py new file mode 100644 index 0000000..7861c98 --- /dev/null +++ b/apps/payments/api/views.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 apps.payments.models import Payment, DiscountCode +from apps.events.models import Event, Registration +from core.authentication import jwt_auth +from apps.payments.api.schemas 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/apps/payments/apps.py b/apps/payments/apps.py new file mode 100644 index 0000000..d94ae34 --- /dev/null +++ b/apps/payments/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class PaymentsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.payments" diff --git a/apps/payments/migrations/0001_initial.py b/apps/payments/migrations/0001_initial.py new file mode 100644 index 0000000..dcc1da7 --- /dev/null +++ b/apps/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/apps/payments/migrations/0002_initial.py b/apps/payments/migrations/0002_initial.py new file mode 100644 index 0000000..cda78c7 --- /dev/null +++ b/apps/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/apps/payments/migrations/0003_payment_registration.py b/apps/payments/migrations/0003_payment_registration.py new file mode 100644 index 0000000..09a239b --- /dev/null +++ b/apps/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/apps/payments/migrations/__init__.py b/apps/payments/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/payments/models.py b/apps/payments/models.py new file mode 100644 index 0000000..0edb723 --- /dev/null +++ b/apps/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 core.models import BaseModel +from apps.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/apps/payments/resources.py b/apps/payments/resources.py new file mode 100644 index 0000000..79c1131 --- /dev/null +++ b/apps/payments/resources.py @@ -0,0 +1,44 @@ +from import_export import resources, fields +from import_export.widgets import ForeignKeyWidget, ManyToManyWidget + +from apps.payments.models import Payment, DiscountCode +from apps.events.models import Event +from apps.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/apps/payments/tests/__init__.py b/apps/payments/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/payments/tests/integration/__init__.py b/apps/payments/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/payments/tests/integration/test_payments.py b/apps/payments/tests/integration/test_payments.py new file mode 100644 index 0000000..5a11048 --- /dev/null +++ b/apps/payments/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 core.authentication import create_jwt_token +from apps.events.models import Event, Registration +from apps.payments.models import Payment, DiscountCode +from apps.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("apps.payments.api.views.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("apps.payments.api.views.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("apps.payments.api.views.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("apps.payments.api.views.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("apps.payments.api.views.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/apps/payments/tests/unit/__init__.py b/apps/payments/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/payments/tests/unit/test_payments.py b/apps/payments/tests/unit/test_payments.py new file mode 100644 index 0000000..88e2824 --- /dev/null +++ b/apps/payments/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 apps.payments.admin import DiscountCodeAdmin +from apps.payments.models import DiscountCode, Payment +from apps.payments.resources import DiscountResource, PaymentResource +from apps.events.models import Event +from apps.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/apps/users/admin.py b/apps/users/admin.py new file mode 100644 index 0000000..5357240 --- /dev/null +++ b/apps/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 apps.users.models import User, University, Major +from apps.users.resources import UserResource +from apps.users.tasks import send_verification_email +from core.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/apps/users/api/__init__.py b/apps/users/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/api/meta.py b/apps/users/api/meta.py new file mode 100644 index 0000000..1192a07 --- /dev/null +++ b/apps/users/api/meta.py @@ -0,0 +1,15 @@ +from ninja import Router + +from apps.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/apps/users/api/schemas.py b/apps/users/api/schemas.py new file mode 100644 index 0000000..5f11346 --- /dev/null +++ b/apps/users/api/schemas.py @@ -0,0 +1,129 @@ +"""Authentication-related API schemas.""" + +from ninja import Schema, ModelSchema +from typing import Optional + +from apps.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/apps/users/api/views.py b/apps/users/api/views.py new file mode 100644 index 0000000..5d306a4 --- /dev/null +++ b/apps/users/api/views.py @@ -0,0 +1,403 @@ +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 apps.users.models import User, Major, University +from apps.users.tasks import send_verification_email, send_password_reset_email +from apps.users.api.schemas import ( + PasswordResetConfirmSchema, + PasswordResetRequestSchema, + TokenRefreshIn, + TokenSchema, + UserListSchema, + UserLoginSchema, + UserProfileSchema, + UserRegistrationSchema, + UserUpdateSchema, + UsernameCheckSchema, +) +from core.api.schemas import ErrorSchema, MessageSchema +from core.authentication import create_jwt_token, create_refresh_token, jwt_auth + +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/apps/users/apps.py b/apps/users/apps.py new file mode 100644 index 0000000..a5b2da0 --- /dev/null +++ b/apps/users/apps.py @@ -0,0 +1,9 @@ +from django.apps import AppConfig + + +class UsersConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.users" + + def ready(self): + import apps.users.signals diff --git a/apps/users/fixtures/agile.json b/apps/users/fixtures/agile.json new file mode 100644 index 0000000..d3104f8 --- /dev/null +++ b/apps/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/apps/users/fixtures/users.json b/apps/users/fixtures/users.json new file mode 100644 index 0000000..77d41c1 --- /dev/null +++ b/apps/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/apps/users/migrations/0001_initial.py b/apps/users/migrations/0001_initial.py new file mode 100644 index 0000000..0e913fe --- /dev/null +++ b/apps/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/apps/users/migrations/0002_alter_user_university.py b/apps/users/migrations/0002_alter_user_university.py new file mode 100644 index 0000000..74c28a6 --- /dev/null +++ b/apps/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/apps/users/migrations/0003_alter_user_university.py b/apps/users/migrations/0003_alter_user_university.py new file mode 100644 index 0000000..2e40608 --- /dev/null +++ b/apps/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/apps/users/migrations/0004_major_university_models.py b/apps/users/migrations/0004_major_university_models.py new file mode 100644 index 0000000..b3ccfe0 --- /dev/null +++ b/apps/users/migrations/0004_major_university_models.py @@ -0,0 +1,372 @@ +from django.db import migrations, models +import django.db.models.deletion + + +MAJOR_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', 'علوم تربیتی'), + ('LIT_FA', 'زبان و ادبیات فارسی'), + ('LIT_EN', 'زبان و ادبیات انگلیسی'), + ('LIT_AR', 'زبان و ادبیات عربی'), + ('TRAN_EN', 'مترجمی زبان انگلیسی'), + ('ART', 'هنرهای تجسمی'), + ('GRAPH', 'گرافیک'), + ('MUSIC', 'موسیقی'), + ('THEAT', 'نمایش و تئاتر')] + +UNIVERSITY_CHOICES = [('GILAN', 'دانشگاه گیلان'), + ('UT', 'دانشگاه تهران'), + ('AUT', 'دانشگاه صنعتی امیرکبیر'), + ('SHARIF', 'دانشگاه صنعتی شریف'), + ('SBU', 'دانشگاه شهید بهشتی'), + ('IUST', 'دانشگاه علم و صنعت ایران'), + ('KNTU', 'دانشگاه صنعتی خواجه\u200cنصیر'), + ('MODARES', 'دانشگاه تربیت مدرس'), + ('ALLAMEH', 'دانشگاه علامه طباطبایی'), + ('KHARAZMI', 'دانشگاه خوارزمی'), + ('ISFAHAN_UNI', 'دانشگاه اصفهان'), + ('IUT', 'دانشگاه صنعتی اصفهان'), + ('SHIRAZ_UNI', 'دانشگاه شیراز'), + ('SHIRAZ_TECH', 'دانشگاه صنعتی شیراز'), + ('TABRIZ_UNI', 'دانشگاه تبریز'), + ('FERDOWSI', 'دانشگاه فردوسی مشهد'), + ('IMAMREZA', 'دانشگاه بین المللی امام رضا مشهد'), + ('RAZI', 'دانشگاه رازی'), + ('SHAHRKORD', 'دانشگاه شهرکرد'), + ('BUALI', 'دانشگاه بوعلی\u200cسینا'), + ('KURDISTAN', 'دانشگاه کردستان'), + ('YAZD_UNI', 'دانشگاه یزد'), + ('KERMAN_UNI', 'دانشگاه شهید باهنر کرمان'), + ('MAZANDARAN', 'دانشگاه مازندران'), + ('GOLESTAN', 'دانشگاه گلستان'), + ('URMIA', 'دانشگاه ارومیه'), + ('ZANJAN', 'دانشگاه زنجان'), + ('ARDABIL', 'دانشگاه محقق اردبیلی'), + ('SEMNAN', 'دانشگاه سمنان'), + ('SHAHROOD', 'دانشگاه صنعتی شاهرود'), + ('QOM_UNI', 'دانشگاه قم'), + ('QOM_TECH', 'دانشگاه صنعتی قم'), + ('IKIU', 'دانشگاه بین\u200cالمللی امام خمینی قزوین'), + ('MAL_ASHTAR', 'دانشگاه صنعتی مالک\u200cاشتر'), + ('SAHAND', 'دانشگاه صنعتی سهند'), + ('BABOL_NOSH', 'دانشگاه صنعتی نوشیروانی بابل'), + ('BIRGAND', 'دانشگاه بیرجند'), + ('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', 'دانشگاه علوم و معارف قرآن کریم')] + + +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=MAJOR_CHOICES, + editable=False, + max_length=16, + null=True, + ), + ), + migrations.AlterField( + model_name="user", + name="legacy_university", + field=models.CharField( + blank=True, + choices=UNIVERSITY_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/apps/users/migrations/0005_populate_major_university.py b/apps/users/migrations/0005_populate_major_university.py new file mode 100644 index 0000000..51c2801 --- /dev/null +++ b/apps/users/migrations/0005_populate_major_university.py @@ -0,0 +1,316 @@ +from django.db import migrations + + +MAJOR_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', 'علوم تربیتی'), + ('LIT_FA', 'زبان و ادبیات فارسی'), + ('LIT_EN', 'زبان و ادبیات انگلیسی'), + ('LIT_AR', 'زبان و ادبیات عربی'), + ('TRAN_EN', 'مترجمی زبان انگلیسی'), + ('ART', 'هنرهای تجسمی'), + ('GRAPH', 'گرافیک'), + ('MUSIC', 'موسیقی'), + ('THEAT', 'نمایش و تئاتر')] + +UNIVERSITY_CHOICES = [('GILAN', 'دانشگاه گیلان'), + ('UT', 'دانشگاه تهران'), + ('AUT', 'دانشگاه صنعتی امیرکبیر'), + ('SHARIF', 'دانشگاه صنعتی شریف'), + ('SBU', 'دانشگاه شهید بهشتی'), + ('IUST', 'دانشگاه علم و صنعت ایران'), + ('KNTU', 'دانشگاه صنعتی خواجه\u200cنصیر'), + ('MODARES', 'دانشگاه تربیت مدرس'), + ('ALLAMEH', 'دانشگاه علامه طباطبایی'), + ('KHARAZMI', 'دانشگاه خوارزمی'), + ('ISFAHAN_UNI', 'دانشگاه اصفهان'), + ('IUT', 'دانشگاه صنعتی اصفهان'), + ('SHIRAZ_UNI', 'دانشگاه شیراز'), + ('SHIRAZ_TECH', 'دانشگاه صنعتی شیراز'), + ('TABRIZ_UNI', 'دانشگاه تبریز'), + ('FERDOWSI', 'دانشگاه فردوسی مشهد'), + ('IMAMREZA', 'دانشگاه بین المللی امام رضا مشهد'), + ('RAZI', 'دانشگاه رازی'), + ('SHAHRKORD', 'دانشگاه شهرکرد'), + ('BUALI', 'دانشگاه بوعلی\u200cسینا'), + ('KURDISTAN', 'دانشگاه کردستان'), + ('YAZD_UNI', 'دانشگاه یزد'), + ('KERMAN_UNI', 'دانشگاه شهید باهنر کرمان'), + ('MAZANDARAN', 'دانشگاه مازندران'), + ('GOLESTAN', 'دانشگاه گلستان'), + ('URMIA', 'دانشگاه ارومیه'), + ('ZANJAN', 'دانشگاه زنجان'), + ('ARDABIL', 'دانشگاه محقق اردبیلی'), + ('SEMNAN', 'دانشگاه سمنان'), + ('SHAHROOD', 'دانشگاه صنعتی شاهرود'), + ('QOM_UNI', 'دانشگاه قم'), + ('QOM_TECH', 'دانشگاه صنعتی قم'), + ('IKIU', 'دانشگاه بین\u200cالمللی امام خمینی قزوین'), + ('MAL_ASHTAR', 'دانشگاه صنعتی مالک\u200cاشتر'), + ('SAHAND', 'دانشگاه صنعتی سهند'), + ('BABOL_NOSH', 'دانشگاه صنعتی نوشیروانی بابل'), + ('BIRGAND', 'دانشگاه بیرجند'), + ('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', 'دانشگاه علوم و معارف قرآن کریم')] + + +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 MAJOR_CHOICES: + obj, _ = Major.objects.update_or_create( + code=code, + defaults={"name": label}, + ) + major_map[code] = obj + + university_map = {} + for code, label in UNIVERSITY_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/apps/users/migrations/0006_remove_legacy_fields.py b/apps/users/migrations/0006_remove_legacy_fields.py new file mode 100644 index 0000000..75de033 --- /dev/null +++ b/apps/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/apps/users/migrations/__init__.py b/apps/users/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/models.py b/apps/users/models.py new file mode 100644 index 0000000..097b39c --- /dev/null +++ b/apps/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 core.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 apps.users.tasks import send_email_verified_success + send_email_verified_success.delay(self.id) + except Exception: + pass diff --git a/apps/users/resources.py b/apps/users/resources.py new file mode 100644 index 0000000..ff97d88 --- /dev/null +++ b/apps/users/resources.py @@ -0,0 +1,29 @@ +from import_export import resources, fields +from import_export.widgets import BooleanWidget + +from apps.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/apps/users/signals.py b/apps/users/signals.py new file mode 100644 index 0000000..fa23f36 --- /dev/null +++ b/apps/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 apps.users.models import User +from apps.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/apps/users/tasks.py b/apps/users/tasks.py new file mode 100644 index 0000000..9ce2a68 --- /dev/null +++ b/apps/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 apps.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/apps/users/tests/__init__.py b/apps/users/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/tests/integration/__init__.py b/apps/users/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/tests/integration/test_users.py b/apps/users/tests/integration/test_users.py new file mode 100644 index 0000000..797aca4 --- /dev/null +++ b/apps/users/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 apps.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("apps.users.tasks.send_verification_email.delay"), + mock.patch("apps.users.signals.send_verification_email.delay"), + mock.patch("apps.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/apps/users/tests/unit/__init__.py b/apps/users/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/users/tests/unit/test_users.py b/apps/users/tests/unit/test_users.py new file mode 100644 index 0000000..603a414 --- /dev/null +++ b/apps/users/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 apps.users.models import User, Major, University +from apps.users.resources import UserResource +from apps.users.signals import send_verification_email_on_registration +from apps.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("apps.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("apps.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("apps.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("apps.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("apps.users.signals.send_verification_email.delay") + @mock.patch("apps.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("apps.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("apps.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("apps.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("apps.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("apps.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("apps.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("apps.users.signals.send_verification_email.delay") + patcher.start() + self.addCleanup(patcher.stop) + + @override_settings(DEFAULT_FROM_EMAIL="no-reply@example.com") + @mock.patch("apps.users.tasks.send_mail") + @mock.patch("apps.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("apps.users.tasks.send_mail") + @mock.patch("apps.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("apps.users.tasks.send_mail") + @mock.patch("apps.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( + "apps.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/celerybeat-schedule b/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/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..bfed38d --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,3 @@ +from config.services.celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/config/api.py b/config/api.py new file mode 100644 index 0000000..374fb53 --- /dev/null +++ b/config/api.py @@ -0,0 +1,23 @@ +from ninja import Router + +from apps.blog.api.views import blog_router +from apps.certificates.api.views import certificates_router +from apps.communications.api.views import communications_router +from apps.events.api.views import events_router +from apps.gallery.api.views import gallery_router +from apps.payments.api.views import payments_router +from apps.users.api.meta import meta_router +from apps.users.api.views import auth_router +from core.api.views import health_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/config/asgi.py b/config/asgi.py new file mode 100644 index 0000000..e5167bb --- /dev/null +++ b/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/config/services/celery.py b/config/services/celery.py new file mode 100644 index 0000000..f848576 --- /dev/null +++ b/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': 'apps.communications.tasks.send_event_reminders', + 'schedule': crontab(minute=0, hour='*/1'), + 'description': 'Runs hourly to notify about upcoming events.', + }, + 'send-weekly-newsletter': { + 'task': 'apps.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': 'apps.communications.tasks.cleanup_expired_tokens', + 'schedule': crontab(hour=2, minute=0), + 'description': 'Runs daily at 02:00 UTC.', + }, + 'process-scheduled-announcements': { + 'task': 'apps.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/config/services/location.py b/config/services/location.py new file mode 100644 index 0000000..0bf0ace --- /dev/null +++ b/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/config/services/notifications.py b/config/services/notifications.py new file mode 100644 index 0000000..350cc5d --- /dev/null +++ b/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/config/services/unfold.py b/config/services/unfold.py new file mode 100644 index 0000000..1024445 --- /dev/null +++ b/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/config/services/zarinpal.py b/config/services/zarinpal.py new file mode 100644 index 0000000..51ec1fa --- /dev/null +++ b/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/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..f4ba27c --- /dev/null +++ b/config/settings/base.py @@ -0,0 +1,240 @@ +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 = [ + "core", + "apps.users", + "apps.blog", + "apps.gallery", + "apps.events", + "apps.certificates", + "apps.communications", + "apps.payments", +] + +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 / CSRF Settings +CORS_ALLOWED_ORIGINS = config( + 'CORS_ALLOWED_ORIGINS', + default='https://east-guilan-ce.ir', +).split(',') +CORS_ALLOW_CREDENTIALS = config('CORS_ALLOW_CREDENTIALS', default=True, cast=bool) +CSRF_TRUSTED_ORIGINS = config( + 'CSRF_TRUSTED_ORIGINS', + default='https://east-guilan-ce.ir', +).split(',') +CSRF_COOKIE_SECURE = config('CSRF_COOKIE_SECURE', default=True, cast=bool) +SESSION_COOKIE_SECURE = config('SESSION_COOKIE_SECURE', default=True, cast=bool) + +# 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') + +if DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql": + 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/config/settings/development.py b/config/settings/development.py new file mode 100644 index 0000000..c06d3d5 --- /dev/null +++ b/config/settings/development.py @@ -0,0 +1,40 @@ +from .base import * + +DEBUG = True + +# Additional development settings +INTERNAL_IPS = [ + "127.0.0.1", +] + +# Local frontend/backend wiring +ALLOWED_HOSTS = [ + "127.0.0.1", + "localhost", +] +CORS_ALLOWED_ORIGINS = [ + "http://localhost:8080", + "http://127.0.0.1:8080", +] +CSRF_TRUSTED_ORIGINS = [ + "http://localhost:8080", + "http://127.0.0.1:8080", +] +CSRF_COOKIE_SECURE = False +SESSION_COOKIE_SECURE = False +SECURE_SSL_REDIRECT = False +SECURE_HSTS_SECONDS = 0 +SECURE_HSTS_INCLUDE_SUBDOMAINS = False +SECURE_CONTENT_TYPE_NOSNIFF = False +SECURE_BROWSER_XSS_FILTER = False +X_FRAME_OPTIONS = "SAMEORIGIN" + +# Email backend for development +EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' + +# Disable caching in development +CACHES = { + 'default': { + 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + } +} diff --git a/config/settings/production.py b/config/settings/production.py new file mode 100644 index 0000000..f0b9bb5 --- /dev/null +++ b/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/config/settings/test.py b/config/settings/test.py new file mode 100644 index 0000000..11e528e --- /dev/null +++ b/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/config/urls.py b/config/urls.py new file mode 100644 index 0000000..740270d --- /dev/null +++ b/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 config.api 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/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..85585bf --- /dev/null +++ b/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/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1 @@ + diff --git a/core/admin.py b/core/admin.py new file mode 100644 index 0000000..36387e0 --- /dev/null +++ b/core/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/core/api/__init__.py b/core/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/api/__init__.py @@ -0,0 +1 @@ + diff --git a/core/api/schemas.py b/core/api/schemas.py new file mode 100644 index 0000000..17f5d9f --- /dev/null +++ b/core/api/schemas.py @@ -0,0 +1,13 @@ +from typing import Optional + +from ninja import Schema + + +class MessageSchema(Schema): + message: str + + +class ErrorSchema(Schema): + error: str + details: Optional[str] = None + diff --git a/core/api/views.py b/core/api/views.py new file mode 100644 index 0000000..448ef80 --- /dev/null +++ b/core/api/views.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/core/apps.py b/core/apps.py new file mode 100644 index 0000000..c751670 --- /dev/null +++ b/core/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class CoreConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "core" + diff --git a/core/authentication.py b/core/authentication.py new file mode 100644 index 0000000..4e52f87 --- /dev/null +++ b/core/authentication.py @@ -0,0 +1,52 @@ +from datetime import UTC, datetime, timedelta + +import jwt +from django.conf import settings +from ninja.security import HttpBearer + +from apps.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): + 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): + 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/core/choices.py b/core/choices.py new file mode 100644 index 0000000..e4599e2 --- /dev/null +++ b/core/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/core/models.py b/core/models.py new file mode 100644 index 0000000..aee1982 --- /dev/null +++ b/core/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/core/templatetags/__init__.py b/core/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/templatetags/jalali.py b/core/templatetags/jalali.py new file mode 100644 index 0000000..380f791 --- /dev/null +++ b/core/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/manage.py b/manage.py new file mode 100644 index 0000000..1709087 --- /dev/null +++ b/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/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7bd8eee --- /dev/null +++ b/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/static/css/styles.css b/static/css/styles.css new file mode 100644 index 0000000..54b5932 --- /dev/null +++ b/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/static/img/logo.png b/static/img/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..c8011eaae77bdb5f2e6b960a339288200379a8b5 GIT binary patch literal 144230 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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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 + await window.pushNotificationManager.setupPushNotifications(vapidPublicKey) diff --git a/static/js/scripts.js b/static/js/scripts.js new file mode 100644 index 0000000..5d671c4 --- /dev/null +++ b/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/static/js/sw.js b/static/js/sw.js new file mode 100644 index 0000000..8d94e03 --- /dev/null +++ b/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/templates/emails/announcement_email.html b/templates/emails/announcement_email.html new file mode 100644 index 0000000..7a59b21 --- /dev/null +++ b/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/templates/emails/event_announcement.html b/templates/emails/event_announcement.html new file mode 100644 index 0000000..9a4b20e --- /dev/null +++ b/templates/emails/event_announcement.html @@ -0,0 +1,19 @@ + + + +
+ + + + + + +
اطلاعیه
+

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

+
{{ body_html|safe }}
+ +
© {% now 'Y' %} انجمن علمی
+
+ diff --git a/templates/emails/event_invite_non_registered.html b/templates/emails/event_invite_non_registered.html new file mode 100644 index 0000000..5918483 --- /dev/null +++ b/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/templates/emails/event_invite_non_registered.txt b/templates/emails/event_invite_non_registered.txt new file mode 100644 index 0000000..8e95aeb --- /dev/null +++ b/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/templates/emails/event_registration_cancellation.html b/templates/emails/event_registration_cancellation.html new file mode 100644 index 0000000..9bb0447 --- /dev/null +++ b/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/templates/emails/event_registration_confirmation.html b/templates/emails/event_registration_confirmation.html new file mode 100644 index 0000000..b48c52e --- /dev/null +++ b/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/templates/emails/event_reminder.html b/templates/emails/event_reminder.html new file mode 100644 index 0000000..e79e822 --- /dev/null +++ b/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/templates/emails/newsletter_confirmation.html b/templates/emails/newsletter_confirmation.html new file mode 100644 index 0000000..c6de788 --- /dev/null +++ b/templates/emails/newsletter_confirmation.html @@ -0,0 +1,108 @@ + + + + + + تأیید اشتراک خبرنامه + + + +
+
+ +

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

+
+ +

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

+ +

سلام،

+ +

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

+ + + +

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

+

{{ confirmation_url }}

+ +

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

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

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

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

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

+

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

+
+ +
+

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

+ +

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

+ + + +

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

+

{{ reset_url }}

+ +

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

+ +

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

+ +

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

+
+ + + + diff --git a/templates/emails/skyroom_credentials.html b/templates/emails/skyroom_credentials.html new file mode 100644 index 0000000..3223fe5 --- /dev/null +++ b/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/templates/emails/verification_email.html b/templates/emails/verification_email.html new file mode 100644 index 0000000..ee5686b --- /dev/null +++ b/templates/emails/verification_email.html @@ -0,0 +1,61 @@ + + + + + + + +
+ + + + + + + + + + + + +
+

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

+

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

+
+

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

+

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

+ + + +

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

+

+ {{ verification_url }} +

+ +

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

+ +

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

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

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

+

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

+
+ +
+

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

+

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

+ + + +

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

+ +

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

+
+ + + + diff --git a/templates/forms/admin_announcement.html b/templates/forms/admin_announcement.html new file mode 100644 index 0000000..ea05526 --- /dev/null +++ b/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 %}