init
This commit is contained in:
67
.env.example
Normal file
67
.env.example
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Let's Encrypt
|
||||||
|
NEXT_HOST=frontend-host-domain.example
|
||||||
|
DJANGO_HOST=backend-host-domain.example
|
||||||
|
LETSENCRYPT_EMAIL=admin@backend-host-domain.example
|
||||||
|
|
||||||
|
|
||||||
|
# Gunicorn tuning
|
||||||
|
GUNICORN_WORKERS=3
|
||||||
|
GUNICORN_THREADS=2
|
||||||
|
|
||||||
|
|
||||||
|
# Django Settings
|
||||||
|
DJANGO_SETTINGS_MODULE=config.settings.production
|
||||||
|
SECRET_KEY=DJANGO_SECRET_KEY
|
||||||
|
DEBUG=False
|
||||||
|
ALLOWED_HOSTS=frontend-host-domain.example,api-host.example
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DB_ENGINE=django.db.backends.postgresql
|
||||||
|
DB_NAME=db-name
|
||||||
|
DB_USER=db-user
|
||||||
|
DB_PASSWORD=db-password
|
||||||
|
DB_HOST=db-host
|
||||||
|
DB_PORT=5432
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_PASSWORD=redis-password
|
||||||
|
REDIS_URL=redis://:redis-password@redis:6379/0
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
CELERY_BROKER_URL=redis://:redis-password@redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND=redis://:redis-password@redis:6379/1
|
||||||
|
|
||||||
|
# Email Settings
|
||||||
|
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||||
|
EMAIL_HOST=email-host
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_USE_TLS=True
|
||||||
|
EMAIL_HOST_USER=smtp-user
|
||||||
|
EMAIL_HOST_PASSWORD=smtp-password
|
||||||
|
DEFAULT_FROM_EMAIL=email-address
|
||||||
|
|
||||||
|
# JWT Settings
|
||||||
|
JWT_SECRET_KEY=JWT_SECRET_KEY
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_LIFETIME=3600
|
||||||
|
JWT_REFRESH_TOKEN_LIFETIME=86400
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ALLOWED_ORIGINS=https://frontend-host-domain.example
|
||||||
|
|
||||||
|
# ZarinPal
|
||||||
|
ZARINPAL_MERCHANT_ID=****
|
||||||
|
ZARINPAL_USE_SANDBOX=False
|
||||||
|
ZARINPAL_CALLBACK_URL=https://backend-callback-endpoint
|
||||||
|
|
||||||
|
# Front-end
|
||||||
|
FRONTEND_ROOT=https://frontend-host-domain.example
|
||||||
|
FRONTEND_PASSWORD_RESET_PAGE=https://frontend-host-domain.example/reset-password
|
||||||
|
|
||||||
|
# Optional test overrides
|
||||||
|
TEST_DB_ENGINE=django.db.backends.sqlite3
|
||||||
|
TEST_DB_NAME=db.test.sqlite3
|
||||||
|
TEST_DB_USER=
|
||||||
|
TEST_DB_PASSWORD=
|
||||||
|
TEST_DB_HOST=
|
||||||
|
TEST_DB_PORT=
|
||||||
60
.env.test
Normal file
60
.env.test
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
# Let's Encrypt
|
||||||
|
NEXT_HOST=frontend-host-domain.example
|
||||||
|
DJANGO_HOST=backend-host-domain.example
|
||||||
|
LETSENCRYPT_EMAIL=admin@backend-host-domain.example
|
||||||
|
|
||||||
|
|
||||||
|
# Gunicorn tuning
|
||||||
|
GUNICORN_WORKERS=3
|
||||||
|
GUNICORN_THREADS=2
|
||||||
|
|
||||||
|
|
||||||
|
# Django Settings
|
||||||
|
DJANGO_SETTINGS_MODULE=config.settings.test
|
||||||
|
SECRET_KEY=DJANGO_SECRET_KEY
|
||||||
|
DEBUG=False
|
||||||
|
ALLOWED_HOSTS=frontend-host-domain.example,api-host.example
|
||||||
|
|
||||||
|
# Database (Optional)
|
||||||
|
TEST_DB_ENGINE=django.db.backends.sqlite3
|
||||||
|
TEST_DB_NAME=db.test.sqlite3
|
||||||
|
TEST_DB_USER=
|
||||||
|
TEST_DB_PASSWORD=
|
||||||
|
TEST_DB_HOST=
|
||||||
|
TEST_DB_PORT=
|
||||||
|
|
||||||
|
# Redis
|
||||||
|
REDIS_PASSWORD=redis-password
|
||||||
|
REDIS_URL=redis://:redis-password@redis:6379/0
|
||||||
|
|
||||||
|
# Celery
|
||||||
|
CELERY_BROKER_URL=redis://:redis-password@redis:6379/0
|
||||||
|
CELERY_RESULT_BACKEND=redis://:redis-password@redis:6379/1
|
||||||
|
|
||||||
|
# Email Settings
|
||||||
|
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
|
||||||
|
EMAIL_HOST=email-host
|
||||||
|
EMAIL_PORT=587
|
||||||
|
EMAIL_USE_TLS=True
|
||||||
|
EMAIL_HOST_USER=smtp-user
|
||||||
|
EMAIL_HOST_PASSWORD=smtp-password
|
||||||
|
DEFAULT_FROM_EMAIL=email-address
|
||||||
|
|
||||||
|
# JWT Settings
|
||||||
|
JWT_SECRET_KEY=JWT_SECRET_KEY
|
||||||
|
JWT_ALGORITHM=HS256
|
||||||
|
JWT_ACCESS_TOKEN_LIFETIME=3600
|
||||||
|
JWT_REFRESH_TOKEN_LIFETIME=86400
|
||||||
|
|
||||||
|
# CORS
|
||||||
|
CORS_ALLOWED_ORIGINS=https://frontend-host-domain.example
|
||||||
|
|
||||||
|
# ZarinPal
|
||||||
|
ZARINPAL_MERCHANT_ID=****
|
||||||
|
ZARINPAL_USE_SANDBOX=False
|
||||||
|
ZARINPAL_CALLBACK_URL=https://backend-callback-endpoint
|
||||||
|
|
||||||
|
# Front-end
|
||||||
|
FRONTEND_ROOT=https://frontend-host-domain.example
|
||||||
|
FRONTEND_PASSWORD_RESET_PAGE=https://frontend-host-domain.example/reset-password
|
||||||
|
|
||||||
236
.github/workflows/ci-cd.yml
vendored
Normal file
236
.github/workflows/ci-cd.yml
vendored
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
name: CI/CD
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
name: Backend & Frontend Checks
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 30
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_DB: app
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
POSTGRES_USER: app
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U app -d app"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
ports:
|
||||||
|
- 6379:6379
|
||||||
|
options: >-
|
||||||
|
--health-cmd "redis-cli ping"
|
||||||
|
--health-interval 10s
|
||||||
|
--health-timeout 5s
|
||||||
|
--health-retries 5
|
||||||
|
env:
|
||||||
|
SECRET_KEY: github-ci-secret-key
|
||||||
|
DEBUG: "False"
|
||||||
|
DJANGO_SETTINGS_MODULE: config.settings.test
|
||||||
|
ALLOWED_HOSTS: localhost,127.0.0.1,testserver
|
||||||
|
DJANGO_HOST: http://localhost:8000
|
||||||
|
NEXT_HOST: localhost
|
||||||
|
LETSENCRYPT_EMAIL: ci@example.com
|
||||||
|
GUNICORN_WORKERS: "2"
|
||||||
|
GUNICORN_THREADS: "2"
|
||||||
|
DB_ENGINE: django.db.backends.postgresql
|
||||||
|
DB_NAME: app
|
||||||
|
DB_USER: app
|
||||||
|
DB_PASSWORD: password
|
||||||
|
DB_HOST: localhost
|
||||||
|
DB_PORT: "5432"
|
||||||
|
TEST_DB_ENGINE: django.db.backends.postgresql
|
||||||
|
TEST_DB_NAME: app
|
||||||
|
TEST_DB_USER: app
|
||||||
|
TEST_DB_PASSWORD: password
|
||||||
|
TEST_DB_HOST: localhost
|
||||||
|
TEST_DB_PORT: "5432"
|
||||||
|
REDIS_PASSWORD: ""
|
||||||
|
REDIS_URL: redis://localhost:6379/0
|
||||||
|
CELERY_BROKER_URL: redis://localhost:6379/0
|
||||||
|
CELERY_RESULT_BACKEND: redis://localhost:6379/1
|
||||||
|
EMAIL_BACKEND: django.core.mail.backends.console.EmailBackend
|
||||||
|
EMAIL_HOST: localhost
|
||||||
|
EMAIL_PORT: "1025"
|
||||||
|
EMAIL_USE_TLS: "False"
|
||||||
|
EMAIL_HOST_USER: ""
|
||||||
|
EMAIL_HOST_PASSWORD: ""
|
||||||
|
DEFAULT_FROM_EMAIL: noreply@example.com
|
||||||
|
CORS_ALLOWED_ORIGINS: http://localhost:3000
|
||||||
|
FRONTEND_ROOT: http://localhost:3000
|
||||||
|
FRONTEND_PASSWORD_RESET_PAGE: http://localhost:3000/reset-password
|
||||||
|
FRONTEND_CALLBACK_URL: http://localhost:3000/payments/result
|
||||||
|
JWT_SECRET_KEY: test-jwt-key
|
||||||
|
JWT_ALGORITHM: HS256
|
||||||
|
JWT_ACCESS_TOKEN_LIFETIME: "3600"
|
||||||
|
JWT_REFRESH_TOKEN_LIFETIME: "86400"
|
||||||
|
ZARINPAL_MERCHANT_ID: sandbox-merchant
|
||||||
|
ZARINPAL_USE_SANDBOX: "True"
|
||||||
|
ZARINPAL_CALLBACK_URL: http://localhost:8000/payments/zarinpal/callback
|
||||||
|
PYTHON_VERSION: "3.12"
|
||||||
|
NODE_VERSION: "20"
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: ${{ env.PYTHON_VERSION }}
|
||||||
|
cache: pip
|
||||||
|
cache-dependency-path: backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Install backend dependencies
|
||||||
|
working-directory: backend
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
pip install coverage
|
||||||
|
|
||||||
|
- name: Prepare .env from template
|
||||||
|
run: |
|
||||||
|
cp .env.test .env
|
||||||
|
python - <<'PY'
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
env_path = Path(".env")
|
||||||
|
overrides = {
|
||||||
|
"SECRET_KEY": os.environ["SECRET_KEY"],
|
||||||
|
"DJANGO_SETTINGS_MODULE": os.environ["DJANGO_SETTINGS_MODULE"],
|
||||||
|
"ALLOWED_HOSTS": os.environ["ALLOWED_HOSTS"],
|
||||||
|
"DJANGO_HOST": os.environ["DJANGO_HOST"],
|
||||||
|
"NEXT_HOST": os.environ["NEXT_HOST"],
|
||||||
|
"LETSENCRYPT_EMAIL": os.environ["LETSENCRYPT_EMAIL"],
|
||||||
|
"GUNICORN_WORKERS": os.environ["GUNICORN_WORKERS"],
|
||||||
|
"GUNICORN_THREADS": os.environ["GUNICORN_THREADS"],
|
||||||
|
"DB_ENGINE": os.environ["DB_ENGINE"],
|
||||||
|
"DB_NAME": os.environ["DB_NAME"],
|
||||||
|
"DB_USER": os.environ["DB_USER"],
|
||||||
|
"DB_PASSWORD": os.environ["DB_PASSWORD"],
|
||||||
|
"DB_HOST": os.environ["DB_HOST"],
|
||||||
|
"DB_PORT": os.environ["DB_PORT"],
|
||||||
|
"TEST_DB_ENGINE": os.environ["TEST_DB_ENGINE"],
|
||||||
|
"TEST_DB_NAME": os.environ["TEST_DB_NAME"],
|
||||||
|
"TEST_DB_USER": os.environ["TEST_DB_USER"],
|
||||||
|
"TEST_DB_PASSWORD": os.environ["TEST_DB_PASSWORD"],
|
||||||
|
"TEST_DB_HOST": os.environ["TEST_DB_HOST"],
|
||||||
|
"TEST_DB_PORT": os.environ["TEST_DB_PORT"],
|
||||||
|
"REDIS_PASSWORD": os.environ["REDIS_PASSWORD"],
|
||||||
|
"REDIS_URL": os.environ["REDIS_URL"],
|
||||||
|
"CELERY_BROKER_URL": os.environ["CELERY_BROKER_URL"],
|
||||||
|
"CELERY_RESULT_BACKEND": os.environ["CELERY_RESULT_BACKEND"],
|
||||||
|
"EMAIL_BACKEND": os.environ["EMAIL_BACKEND"],
|
||||||
|
"EMAIL_HOST": os.environ["EMAIL_HOST"],
|
||||||
|
"EMAIL_PORT": os.environ["EMAIL_PORT"],
|
||||||
|
"EMAIL_USE_TLS": os.environ["EMAIL_USE_TLS"],
|
||||||
|
"EMAIL_HOST_USER": os.environ["EMAIL_HOST_USER"],
|
||||||
|
"EMAIL_HOST_PASSWORD": os.environ["EMAIL_HOST_PASSWORD"],
|
||||||
|
"DEFAULT_FROM_EMAIL": os.environ["DEFAULT_FROM_EMAIL"],
|
||||||
|
"CORS_ALLOWED_ORIGINS": os.environ["CORS_ALLOWED_ORIGINS"],
|
||||||
|
"FRONTEND_ROOT": os.environ["FRONTEND_ROOT"],
|
||||||
|
"FRONTEND_PASSWORD_RESET_PAGE": os.environ["FRONTEND_PASSWORD_RESET_PAGE"],
|
||||||
|
"FRONTEND_CALLBACK_URL": os.environ["FRONTEND_CALLBACK_URL"],
|
||||||
|
"JWT_SECRET_KEY": os.environ["JWT_SECRET_KEY"],
|
||||||
|
"JWT_ALGORITHM": os.environ["JWT_ALGORITHM"],
|
||||||
|
"JWT_ACCESS_TOKEN_LIFETIME": os.environ["JWT_ACCESS_TOKEN_LIFETIME"],
|
||||||
|
"JWT_REFRESH_TOKEN_LIFETIME": os.environ["JWT_REFRESH_TOKEN_LIFETIME"],
|
||||||
|
"ZARINPAL_MERCHANT_ID": os.environ["ZARINPAL_MERCHANT_ID"],
|
||||||
|
"ZARINPAL_USE_SANDBOX": os.environ["ZARINPAL_USE_SANDBOX"],
|
||||||
|
"ZARINPAL_CALLBACK_URL": os.environ["ZARINPAL_CALLBACK_URL"],
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for raw in env_path.read_text().splitlines():
|
||||||
|
if not raw or raw.lstrip().startswith("#"):
|
||||||
|
lines.append(raw)
|
||||||
|
continue
|
||||||
|
key, _, current = raw.partition("=")
|
||||||
|
value = overrides.get(key, current)
|
||||||
|
lines.append(f"{key}={value}")
|
||||||
|
|
||||||
|
env_path.write_text("\n".join(lines) + "\n")
|
||||||
|
PY
|
||||||
|
|
||||||
|
- name: Run database migrations
|
||||||
|
working-directory: backend
|
||||||
|
env:
|
||||||
|
DJANGO_SETTINGS_MODULE: ${{ env.DJANGO_SETTINGS_MODULE }}
|
||||||
|
run: python manage.py migrate --noinput
|
||||||
|
|
||||||
|
- name: Run Django tests with coverage
|
||||||
|
working-directory: backend
|
||||||
|
env:
|
||||||
|
DJANGO_SETTINGS_MODULE: ${{ env.DJANGO_SETTINGS_MODULE }}
|
||||||
|
run: |
|
||||||
|
coverage run --rcfile=.coveragerc manage.py test --settings=config.settings.test --verbosity 2
|
||||||
|
coverage report -m
|
||||||
|
coverage xml
|
||||||
|
coverage html
|
||||||
|
|
||||||
|
- name: Upload backend coverage artifacts
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
with:
|
||||||
|
name: backend-coverage
|
||||||
|
path: |
|
||||||
|
backend/.coverage
|
||||||
|
backend/coverage.xml
|
||||||
|
backend/htmlcov
|
||||||
|
retention-days: 14
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
working-directory: frontend
|
||||||
|
run: npm install --no-audit --no-fund
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: frontend
|
||||||
|
env:
|
||||||
|
CI: "true"
|
||||||
|
run: npm run build
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
name: Deploy to Production
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: test
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
timeout-minutes: 30
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy over SSH
|
||||||
|
uses: appleboy/ssh-action@v1.2.0
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
username: ${{ secrets.DEPLOY_USER }}
|
||||||
|
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
port: ${{ secrets.DEPLOY_PORT }}
|
||||||
|
script: |
|
||||||
|
set -e
|
||||||
|
cd "${{ secrets.DEPLOY_PATH }}"
|
||||||
|
git fetch --prune
|
||||||
|
git reset --hard origin/main
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d --build --remove-orphans
|
||||||
|
docker image prune -f
|
||||||
31
.gitignore
vendored
Normal file
31
.gitignore
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||||
|
|
||||||
|
# dependencies
|
||||||
|
/node_modules
|
||||||
|
|
||||||
|
# next.js
|
||||||
|
/.next/
|
||||||
|
/out/
|
||||||
|
|
||||||
|
# production
|
||||||
|
/build
|
||||||
|
|
||||||
|
# debug
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
.pnpm-debug.log*
|
||||||
|
|
||||||
|
# env files
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# vercel
|
||||||
|
.vercel
|
||||||
|
|
||||||
|
# typescript
|
||||||
|
*.tsbuildinfo
|
||||||
|
next-env.d.ts
|
||||||
|
|
||||||
|
.vscode
|
||||||
674
LICENSE
Normal file
674
LICENSE
Normal file
@@ -0,0 +1,674 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 29 June 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
the GNU General Public License is intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users. We, the Free Software Foundation, use the
|
||||||
|
GNU General Public License for most of our software; it applies also to
|
||||||
|
any other work released this way by its authors. You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to prevent others from denying you
|
||||||
|
these rights or asking you to surrender the rights. Therefore, you have
|
||||||
|
certain responsibilities if you distribute copies of the software, or if
|
||||||
|
you modify it: responsibilities to respect the freedom of others.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must pass on to the recipients the same
|
||||||
|
freedoms that you received. You must make sure that they, too, receive
|
||||||
|
or can get the source code. And you must show them these terms so they
|
||||||
|
know their rights.
|
||||||
|
|
||||||
|
Developers that use the GNU GPL protect your rights with two steps:
|
||||||
|
(1) assert copyright on the software, and (2) offer you this License
|
||||||
|
giving you legal permission to copy, distribute and/or modify it.
|
||||||
|
|
||||||
|
For the developers' and authors' protection, the GPL clearly explains
|
||||||
|
that there is no warranty for this free software. For both users' and
|
||||||
|
authors' sake, the GPL requires that modified versions be marked as
|
||||||
|
changed, so that their problems will not be attributed erroneously to
|
||||||
|
authors of previous versions.
|
||||||
|
|
||||||
|
Some devices are designed to deny users access to install or run
|
||||||
|
modified versions of the software inside them, although the manufacturer
|
||||||
|
can do so. This is fundamentally incompatible with the aim of
|
||||||
|
protecting users' freedom to change the software. The systematic
|
||||||
|
pattern of such abuse occurs in the area of products for individuals to
|
||||||
|
use, which is precisely where it is most unacceptable. Therefore, we
|
||||||
|
have designed this version of the GPL to prohibit the practice for those
|
||||||
|
products. If such problems arise substantially in other domains, we
|
||||||
|
stand ready to extend this provision to those domains in future versions
|
||||||
|
of the GPL, as needed to protect the freedom of users.
|
||||||
|
|
||||||
|
Finally, every program is threatened constantly by software patents.
|
||||||
|
States should not allow patents to restrict development and use of
|
||||||
|
software on general-purpose computers, but in those that do, we wish to
|
||||||
|
avoid the special danger that patents applied to a free program could
|
||||||
|
make it effectively proprietary. To prevent this, the GPL assures that
|
||||||
|
patents cannot be used to render the program non-free.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Use with the GNU Affero General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU Affero General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the special requirements of the GNU Affero General Public License,
|
||||||
|
section 13, concerning interaction through a network will apply to the
|
||||||
|
combination as such.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program does terminal interaction, make it output a short
|
||||||
|
notice like this when it starts in an interactive mode:
|
||||||
|
|
||||||
|
<program> Copyright (C) <year> <name of author>
|
||||||
|
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, your program's commands
|
||||||
|
might be different; for a GUI interface, you would use an "about box".
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU GPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
The GNU General Public License does not permit incorporating your program
|
||||||
|
into proprietary programs. If your program is a subroutine library, you
|
||||||
|
may consider it more useful to permit linking proprietary applications with
|
||||||
|
the library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License. But first, please read
|
||||||
|
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||||
39
README.md
Normal file
39
README.md
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# East Guilan CE
|
||||||
|
|
||||||
|
**Full-stack portal** for the East Guilan Computer Engineering Association. The repo hosts a Django/Ninja API backend and a Vite + React + shadcn UI frontend, both orchestrated via Docker Compose.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
- **Backend**: Django + Ninja-based REST API, PostgreSQL, Redis, Celery, Traefik routing, and Prometheus/metrics.
|
||||||
|
- **Frontend**: Vite + React 18 with TanStack Query, shadcn/ui components, RTL layout, and staff-only admin tooling.
|
||||||
|
- **Dev surface**: `docker-compose.yml` brings up Traefik, PostgreSQL, Redis, Django (web/worker/beat), frontend, static/nginx, Prometheus exporters, etc.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
1. **API layer (`backend/api`)** exposes auth, blog, event, gallery, payment, and communication routers with Ninja schemas. JWT authentication protects secured routes.
|
||||||
|
2. **Django apps** (`users`, `blog`, `events`, `payments`, `gallery`, etc.) provide models, management commands, and async tasks/Celery workers.
|
||||||
|
3. **Frontend** (`frontend/`) consumes the API, handles login flows, event listings, the admin dashboard, rich markdown rendering, and sonner toasts.
|
||||||
|
|
||||||
|
## Getting started
|
||||||
|
1. Copy `.env.example` to `.env` and configure secrets.
|
||||||
|
2. `docker compose up --build` to start the full stack.
|
||||||
|
3. Backend tests: `docker compose exec backend python manage.py test --settings=config.settings.test`.
|
||||||
|
4. Frontend dev: `docker compose exec frontend npm run dev -- --host`.
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|
### Main pages:
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
### User/Admin Dashboard:
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
### Event Pages:
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
### Blog Pages:
|
||||||
|
|
||||||
|
Soon...
|
||||||
|
|
||||||
|
See the [`backend/README.md`](backend/README.md) and [`frontend/README.md`](frontend/README.md) for detailed instructions.
|
||||||
26
alert.rules.yml
Normal file
26
alert.rules.yml
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
groups:
|
||||||
|
- name: storage
|
||||||
|
rules:
|
||||||
|
- alert: DiskSpaceWarning
|
||||||
|
expr: (node_filesystem_size_bytes{fstype!~"tmpfs|overlay"} - node_filesystem_free_bytes{fstype!~"tmpfs|overlay"}) / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"} > 0.80
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Disk >80% on {{ $labels.instance }} {{ $labels.mountpoint }}"
|
||||||
|
|
||||||
|
- alert: DiskSpaceCritical
|
||||||
|
expr: (node_filesystem_size_bytes{fstype!~"tmpfs|overlay"} - node_filesystem_free_bytes{fstype!~"tmpfs|overlay"}) / node_filesystem_size_bytes{fstype!~"tmpfs|overlay"} > 0.90
|
||||||
|
for: 5m
|
||||||
|
labels:
|
||||||
|
severity: critical
|
||||||
|
annotations:
|
||||||
|
summary: "Disk >90% on {{ $labels.instance }} {{ $labels.mountpoint }}"
|
||||||
|
|
||||||
|
- alert: DiskInodesLow
|
||||||
|
expr: (node_filesystem_files{fstype!~"tmpfs|overlay"} - node_filesystem_files_free{fstype!~"tmpfs|overlay"}) / node_filesystem_files{fstype!~"tmpfs|overlay"} > 0.80
|
||||||
|
for: 10m
|
||||||
|
labels:
|
||||||
|
severity: warning
|
||||||
|
annotations:
|
||||||
|
summary: "Inodes >80% on {{ $labels.instance }} {{ $labels.mountpoint }}"
|
||||||
14
alertmanager.yml
Normal file
14
alertmanager.yml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
route:
|
||||||
|
receiver: default
|
||||||
|
routes:
|
||||||
|
- matchers:
|
||||||
|
- severity="critical"
|
||||||
|
receiver: critical
|
||||||
|
|
||||||
|
receivers:
|
||||||
|
- name: default
|
||||||
|
email_configs:
|
||||||
|
- to: you@example.com
|
||||||
|
- name: critical
|
||||||
|
email_configs:
|
||||||
|
- to: oncall@example.com
|
||||||
31
backend/.coveragerc
Normal file
31
backend/.coveragerc
Normal file
@@ -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
|
||||||
139
backend/.gitignore
vendored
Normal file
139
backend/.gitignore
vendored
Normal file
@@ -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
|
||||||
34
backend/Dockerfile
Normal file
34
backend/Dockerfile
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
FROM python:3.13-slim
|
||||||
|
|
||||||
|
# Set environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Set work directory
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install system dependencies
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends \
|
||||||
|
postgresql-client \
|
||||||
|
build-essential \
|
||||||
|
libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
COPY requirements.txt /app/
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
# Copy project
|
||||||
|
COPY . /app/
|
||||||
|
|
||||||
|
# Create directories for static and media files
|
||||||
|
RUN mkdir -p /app/static /app/media
|
||||||
|
# COPY ./static/ /app/static/
|
||||||
|
|
||||||
|
# Collect static files
|
||||||
|
RUN python manage.py collectstatic --noinput || true
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers=3", "--threads=2", "--timeout=60"]
|
||||||
38
backend/README.md
Normal file
38
backend/README.md
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Backend
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
- Django 5+ with Ninja API routers, JWT auth, and Ninja schemas.
|
||||||
|
- PostgreSQL + Redis + Celery + Gunicorn orchestrated via Docker Compose.
|
||||||
|
- Traefik handles TLS termination and routing to `/api`, `/admin`, `/static`, `/media`.
|
||||||
|
- Metrics exporters (Prometheus, node exporter, PostgreSQL exporter) are wired in `docker-compose.yml`.
|
||||||
|
|
||||||
|
## Key apps
|
||||||
|
|
||||||
|
| App | Responsibilities |
|
||||||
|
| --- | --- |
|
||||||
|
| `users` | Custom `User` model, email verification, password resets, soft deletes. |
|
||||||
|
| `blog` | Posts, comments, categories/tags, likes, admin delete/restore operations. |
|
||||||
|
| `events` | Events, registrations, invitations, registration emails, Celery tasks. |
|
||||||
|
| `payments` | Discount codes, payment tracking linked to registrations. |
|
||||||
|
|
||||||
|
## API highlights
|
||||||
|
- **Authentication** (`/api/auth/*`): register, login, refresh, profile, delete profile picture, deleted users, filtered user lists.
|
||||||
|
- **Blog** (`/api/blog/*`): posts/comments, soft delete/restore, likes, categories/tags APIs.
|
||||||
|
- **Events** (`/api/events/*`): list, detail, create/update/delete, admin endpoints for event/registration detail and paginated/filterable registrations.
|
||||||
|
- **Payments** (`/api/payments/*`): create payment, get by ref, discounts.
|
||||||
|
|
||||||
|
## Running locally
|
||||||
|
```bash
|
||||||
|
docker compose build backend
|
||||||
|
docker compose run --rm backend python manage.py migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
```bash
|
||||||
|
docker compose run --rm backend python manage.py test --settings=config.settings.test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin tooling
|
||||||
|
- Ninja routers live under `backend/api/views`. Schemas are in `backend/api/schemas`.
|
||||||
|
- JWT auth files: `backend/api/authentication.py`.
|
||||||
|
- Celery configs in `backend/config/services/celery.py` and tasks (events, users, communications).
|
||||||
41
backend/api/authentication.py
Normal file
41
backend/api/authentication.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from ninja.security import HttpBearer
|
||||||
|
from datetime import datetime, timedelta, UTC
|
||||||
|
import jwt
|
||||||
|
|
||||||
|
from users.models import User
|
||||||
|
|
||||||
|
class JWTAuth(HttpBearer):
|
||||||
|
def authenticate(self, request, token):
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||||
|
user_id = payload.get('user_id')
|
||||||
|
if user_id:
|
||||||
|
user = User.objects.get(id=user_id, is_email_verified=True, is_active=True)
|
||||||
|
return user
|
||||||
|
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, User.DoesNotExist):
|
||||||
|
pass
|
||||||
|
return None
|
||||||
|
|
||||||
|
def create_jwt_token(user):
|
||||||
|
"""Create JWT token for user"""
|
||||||
|
payload = {
|
||||||
|
'user_id': user.id,
|
||||||
|
'email': user.email,
|
||||||
|
'exp': datetime.now(UTC) + timedelta(seconds=settings.JWT_ACCESS_TOKEN_LIFETIME),
|
||||||
|
'iat': datetime.now(UTC),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||||
|
|
||||||
|
def create_refresh_token(user):
|
||||||
|
"""Create refresh token for user"""
|
||||||
|
payload = {
|
||||||
|
'user_id': user.id,
|
||||||
|
'type': 'refresh',
|
||||||
|
'exp': datetime.now(UTC) + timedelta(seconds=settings.JWT_REFRESH_TOKEN_LIFETIME),
|
||||||
|
'iat': datetime.now(UTC),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||||
|
|
||||||
|
jwt_auth = JWTAuth()
|
||||||
31
backend/api/schemas/__init__.py
Normal file
31
backend/api/schemas/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
"""Aggregate exports for API schemas and shared response payloads."""
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from ninja import Schema
|
||||||
|
|
||||||
|
from api.schemas.auth import *
|
||||||
|
from api.schemas.blog import *
|
||||||
|
from api.schemas.gallery import *
|
||||||
|
from api.schemas.events import *
|
||||||
|
from api.schemas.communications import *
|
||||||
|
from api.schemas.certificates import *
|
||||||
|
|
||||||
|
|
||||||
|
class MessageSchema(Schema):
|
||||||
|
"""Basic success response containing a message."""
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorSchema(Schema):
|
||||||
|
"""Standard error payload with optional details."""
|
||||||
|
error: str
|
||||||
|
details: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_comment_schema() -> None:
|
||||||
|
"""Ensure the self-referential CommentSchema is fully initialized."""
|
||||||
|
CommentSchema.model_rebuild()
|
||||||
|
|
||||||
|
|
||||||
|
rebuild_comment_schema()
|
||||||
129
backend/api/schemas/auth.py
Normal file
129
backend/api/schemas/auth.py
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
"""Authentication-related API schemas."""
|
||||||
|
|
||||||
|
from ninja import Schema, ModelSchema
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from users.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class UserRegistrationSchema(Schema):
|
||||||
|
username: str
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
university: Optional[str] = None
|
||||||
|
student_id: Optional[str] = None
|
||||||
|
year_of_study: Optional[int] = None
|
||||||
|
major: Optional[str] = None
|
||||||
|
|
||||||
|
class UserLoginSchema(Schema):
|
||||||
|
email: str
|
||||||
|
password: str
|
||||||
|
|
||||||
|
class UserProfileSchema(ModelSchema):
|
||||||
|
profile_picture: Optional[str] = None
|
||||||
|
student_id: Optional[str] = None
|
||||||
|
major: Optional[str] = None
|
||||||
|
university: Optional[str] = None
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'username',
|
||||||
|
'email',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'student_id',
|
||||||
|
'year_of_study',
|
||||||
|
'major',
|
||||||
|
'university',
|
||||||
|
'bio',
|
||||||
|
'date_joined',
|
||||||
|
'is_email_verified',
|
||||||
|
'is_active',
|
||||||
|
'is_staff',
|
||||||
|
'is_superuser',
|
||||||
|
'is_deleted',
|
||||||
|
'deleted_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_major(obj):
|
||||||
|
return obj.get_major_display()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_university(obj):
|
||||||
|
return obj.get_university_display()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_profile_picture(obj, context):
|
||||||
|
"""
|
||||||
|
Resolves the absolute URL for the profile picture.
|
||||||
|
`context` contains the request object, which is needed for build_absolute_uri.
|
||||||
|
"""
|
||||||
|
request = context['request']
|
||||||
|
if obj.profile_picture and hasattr(obj.profile_picture, 'url'):
|
||||||
|
return request.build_absolute_uri(obj.profile_picture.url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class UserListSchema(ModelSchema):
|
||||||
|
major: Optional[str] = None
|
||||||
|
university: Optional[str] = None
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = User
|
||||||
|
fields = [
|
||||||
|
'id',
|
||||||
|
'username',
|
||||||
|
'email',
|
||||||
|
'first_name',
|
||||||
|
'last_name',
|
||||||
|
'is_active',
|
||||||
|
'is_staff',
|
||||||
|
'is_superuser',
|
||||||
|
'date_joined',
|
||||||
|
'major',
|
||||||
|
'university',
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_full_name(obj):
|
||||||
|
return obj.get_full_name()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_major(obj):
|
||||||
|
return obj.get_major_display()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_university(obj):
|
||||||
|
return obj.get_university_display()
|
||||||
|
|
||||||
|
class UserUpdateSchema(Schema):
|
||||||
|
first_name: Optional[str] = None
|
||||||
|
last_name: Optional[str] = None
|
||||||
|
bio: Optional[str] = None
|
||||||
|
year_of_study: Optional[int] = None
|
||||||
|
major: Optional[str] = None
|
||||||
|
university: Optional[str] = None
|
||||||
|
student_id: Optional[str] = None
|
||||||
|
|
||||||
|
class TokenSchema(Schema):
|
||||||
|
access_token: str
|
||||||
|
refresh_token: str
|
||||||
|
token_type: str = "bearer"
|
||||||
|
|
||||||
|
class TokenRefreshIn(Schema):
|
||||||
|
refresh_token: str
|
||||||
|
|
||||||
|
class PasswordResetRequestSchema(Schema):
|
||||||
|
email: str
|
||||||
|
|
||||||
|
class PasswordResetConfirmSchema(Schema):
|
||||||
|
token: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
class UsernameCheckSchema(Schema):
|
||||||
|
exists: bool
|
||||||
87
backend/api/schemas/blog.py
Normal file
87
backend/api/schemas/blog.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""Blog API schemas."""
|
||||||
|
|
||||||
|
from ninja import Schema, ModelSchema
|
||||||
|
from typing import Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from blog.models import Category, Tag, Comment
|
||||||
|
|
||||||
|
|
||||||
|
class CategorySchema(ModelSchema):
|
||||||
|
class Config:
|
||||||
|
model = Category
|
||||||
|
model_fields = ['id', 'name', 'slug', 'description']
|
||||||
|
|
||||||
|
class TagSchema(ModelSchema):
|
||||||
|
class Config:
|
||||||
|
model = Tag
|
||||||
|
model_fields = ['id', 'name', 'slug']
|
||||||
|
|
||||||
|
class AuthorSchema(Schema):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
profile_picture: Optional[str] = None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_profile_picture(obj, context):
|
||||||
|
request = context['request']
|
||||||
|
if obj.profile_picture and hasattr(obj.profile_picture, 'url'):
|
||||||
|
return request.build_absolute_uri(obj.profile_picture.url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
class PostListSchema(Schema):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
slug: str
|
||||||
|
excerpt: str
|
||||||
|
author: AuthorSchema
|
||||||
|
featured_image: Optional[str] = None
|
||||||
|
status: str
|
||||||
|
published_at: Optional[datetime] = None
|
||||||
|
category: Optional[CategorySchema] = None
|
||||||
|
tags: List[TagSchema]
|
||||||
|
is_featured: bool
|
||||||
|
created_at: datetime
|
||||||
|
reading_time: int
|
||||||
|
|
||||||
|
class PostDetailSchema(PostListSchema):
|
||||||
|
content: str
|
||||||
|
content_html: str
|
||||||
|
|
||||||
|
class PostCreateSchema(Schema):
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
excerpt: Optional[str] = None
|
||||||
|
category_id: Optional[int] = None
|
||||||
|
tag_ids: Optional[List[int]] = []
|
||||||
|
status: str = "draft"
|
||||||
|
is_featured: bool = False
|
||||||
|
|
||||||
|
class CommentSchema(ModelSchema):
|
||||||
|
author: AuthorSchema
|
||||||
|
replies: List['CommentSchema'] = []
|
||||||
|
post_id: int
|
||||||
|
post_title: str
|
||||||
|
post_slug: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = Comment
|
||||||
|
model_fields = ['id', 'content', 'created_at', 'is_approved']
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_post_id(obj):
|
||||||
|
return obj.post_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_post_title(obj):
|
||||||
|
return obj.post.title
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_post_slug(obj):
|
||||||
|
return obj.post.slug
|
||||||
|
|
||||||
|
class CommentCreateSchema(Schema):
|
||||||
|
content: str
|
||||||
|
parent_id: Optional[int] = None
|
||||||
70
backend/api/schemas/certificates.py
Normal file
70
backend/api/schemas/certificates.py
Normal file
@@ -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]
|
||||||
124
backend/api/schemas/communications.py
Normal file
124
backend/api/schemas/communications.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""Schemas for communications-related endpoints."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional, List
|
||||||
|
|
||||||
|
from ninja import Schema, ModelSchema
|
||||||
|
|
||||||
|
from api.schemas import AuthorSchema
|
||||||
|
from communications.models import (
|
||||||
|
Announcement,
|
||||||
|
NewsletterSubscription,
|
||||||
|
PushNotificationDevice
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementSchema(ModelSchema):
|
||||||
|
author: AuthorSchema
|
||||||
|
content_html: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = Announcement
|
||||||
|
model_fields = [
|
||||||
|
'id', 'title', 'content', 'announcement_type', 'priority',
|
||||||
|
'is_published', 'publish_date', 'send_email', 'send_push',
|
||||||
|
'target_audience', 'email_sent', 'push_sent', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_content_html(obj):
|
||||||
|
return obj.content_html
|
||||||
|
|
||||||
|
class AnnouncementListSchema(Schema):
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
announcement_type: str
|
||||||
|
priority: str
|
||||||
|
author: AuthorSchema
|
||||||
|
is_published: bool
|
||||||
|
publish_date: Optional[datetime] = None
|
||||||
|
target_audience: str
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
class AnnouncementCreateSchema(Schema):
|
||||||
|
title: str
|
||||||
|
content: str
|
||||||
|
announcement_type: str = "general"
|
||||||
|
priority: str = "normal"
|
||||||
|
target_audience: str = "all"
|
||||||
|
is_published: bool = False
|
||||||
|
publish_date: Optional[datetime] = None
|
||||||
|
send_email: bool = False
|
||||||
|
send_push: bool = False
|
||||||
|
|
||||||
|
class AnnouncementUpdateSchema(Schema):
|
||||||
|
title: Optional[str] = None
|
||||||
|
content: Optional[str] = None
|
||||||
|
announcement_type: Optional[str] = None
|
||||||
|
priority: Optional[str] = None
|
||||||
|
target_audience: Optional[str] = None
|
||||||
|
is_published: Optional[bool] = None
|
||||||
|
publish_date: Optional[datetime] = None
|
||||||
|
send_email: Optional[bool] = None
|
||||||
|
send_push: Optional[bool] = None
|
||||||
|
|
||||||
|
class NewsletterSubscriptionSchema(ModelSchema):
|
||||||
|
user: Optional[AuthorSchema] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = NewsletterSubscription
|
||||||
|
model_fields = [
|
||||||
|
'id', 'email', 'is_active', 'subscribed_categories',
|
||||||
|
'confirmed_at', 'created_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
class NewsletterSubscribeSchema(Schema):
|
||||||
|
email: str
|
||||||
|
subscribed_categories: Optional[List[str]] = []
|
||||||
|
|
||||||
|
class NewsletterUnsubscribeSchema(Schema):
|
||||||
|
email: str
|
||||||
|
|
||||||
|
class PushDeviceSchema(ModelSchema):
|
||||||
|
user: AuthorSchema
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = PushNotificationDevice
|
||||||
|
model_fields = [
|
||||||
|
'id', 'device_token', 'device_type', 'is_active', 'created_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
class PushDeviceCreateSchema(Schema):
|
||||||
|
device_token: str
|
||||||
|
device_type: str = "web"
|
||||||
|
|
||||||
|
class PushDeviceUpdateSchema(Schema):
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
class PushNotificationSchema(Schema):
|
||||||
|
title: str
|
||||||
|
body: str
|
||||||
|
data: Optional[dict] = None
|
||||||
|
target_audience: str = "all"
|
||||||
|
|
||||||
|
class MessageResponseSchema(Schema):
|
||||||
|
"""Simple message payload for API responses."""
|
||||||
|
message: str
|
||||||
|
success: bool = True
|
||||||
|
|
||||||
|
class AnnouncementStatsSchema(Schema):
|
||||||
|
"""Summary statistics for announcements."""
|
||||||
|
total_announcements: int
|
||||||
|
published_announcements: int
|
||||||
|
draft_announcements: int
|
||||||
|
urgent_announcements: int
|
||||||
|
email_sent_count: int
|
||||||
|
push_sent_count: int
|
||||||
|
|
||||||
|
class NewsletterStatsSchema(Schema):
|
||||||
|
"""Summary statistics for newsletter subscriptions."""
|
||||||
|
total_subscriptions: int
|
||||||
|
active_subscriptions: int
|
||||||
|
confirmed_subscriptions: int
|
||||||
|
recent_subscriptions: int
|
||||||
247
backend/api/schemas/events.py
Normal file
247
backend/api/schemas/events.py
Normal file
@@ -0,0 +1,247 @@
|
|||||||
|
"""Event and gallery API schemas."""
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
from ninja import ModelSchema, Schema
|
||||||
|
from pydantic import field_validator
|
||||||
|
from typing import Literal, Optional, List
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from api.schemas.blog import AuthorSchema
|
||||||
|
from events.models import Event, Registration
|
||||||
|
from gallery.models import Gallery
|
||||||
|
from payments.models import Payment
|
||||||
|
|
||||||
|
|
||||||
|
class EventGallerySchema(ModelSchema):
|
||||||
|
"""Schema representing gallery items associated with an event."""
|
||||||
|
uploaded_by: AuthorSchema
|
||||||
|
file_size_mb: float
|
||||||
|
markdown_url: str
|
||||||
|
absolute_image_url: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = Gallery
|
||||||
|
model_fields = ['id', 'title', 'description', 'image', 'alt_text',
|
||||||
|
'width', 'height', 'is_public', 'created_at']
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_absolute_image_url(obj, context):
|
||||||
|
request = context['request']
|
||||||
|
if obj.image and hasattr(obj.image, 'url'):
|
||||||
|
return request.build_absolute_uri(obj.image.url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
class EventSchema(ModelSchema):
|
||||||
|
"""Schema providing full event details for API responses."""
|
||||||
|
gallery_images: List[EventGallerySchema]
|
||||||
|
description_html: str
|
||||||
|
registration_count: int
|
||||||
|
absolute_featured_image_url: Optional[str] = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = Event
|
||||||
|
model_fields = [
|
||||||
|
'id', 'title', 'slug', 'description', 'featured_image', 'event_type',
|
||||||
|
'address', 'location', 'online_link', 'start_time', 'end_time',
|
||||||
|
'registration_start_date', 'registration_end_date', 'registration_success_markdown',
|
||||||
|
'capacity', 'price', 'status', 'created_at', 'updated_at'
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_absolute_featured_image_url(obj, context):
|
||||||
|
request = context['request']
|
||||||
|
if obj.featured_image and hasattr(obj.featured_image, 'url'):
|
||||||
|
return request.build_absolute_uri(obj.featured_image.url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_registration_count(obj):
|
||||||
|
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_description_html(obj):
|
||||||
|
return obj.description_html
|
||||||
|
|
||||||
|
|
||||||
|
class EventListSchema(Schema):
|
||||||
|
"""Condensed event representation for list endpoints."""
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
slug: str
|
||||||
|
featured_image: Optional[str] = None
|
||||||
|
absolute_featured_image_url: Optional[str] = None
|
||||||
|
event_type: str
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
registration_start_date: Optional[datetime] = None
|
||||||
|
registration_end_date: Optional[datetime] = None
|
||||||
|
capacity: Optional[int] = None
|
||||||
|
price: Optional[float] = None
|
||||||
|
status: str
|
||||||
|
registration_count: int
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_absolute_featured_image_url(obj, context):
|
||||||
|
request = context['request']
|
||||||
|
if obj.featured_image and hasattr(obj.featured_image, 'url'):
|
||||||
|
return request.build_absolute_uri(obj.featured_image.url)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_registration_count(obj):
|
||||||
|
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
|
||||||
|
|
||||||
|
class EventCreateSchema(Schema):
|
||||||
|
"""Payload for creating events via the API."""
|
||||||
|
title: str
|
||||||
|
description: str
|
||||||
|
event_type: str
|
||||||
|
address: Optional[str] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
online_link: Optional[str] = None
|
||||||
|
start_time: datetime
|
||||||
|
end_time: datetime
|
||||||
|
registration_start_date: Optional[datetime] = None
|
||||||
|
registration_end_date: Optional[datetime] = None
|
||||||
|
capacity: Optional[int] = None
|
||||||
|
price: Optional[float] = None
|
||||||
|
status: str = "draft"
|
||||||
|
gallery_image_ids: Optional[List[int]] = []
|
||||||
|
|
||||||
|
class EventUpdateSchema(Schema):
|
||||||
|
"""Payload for updating events via the API."""
|
||||||
|
title: Optional[str] = None
|
||||||
|
description: Optional[str] = None
|
||||||
|
event_type: Optional[str] = None
|
||||||
|
address: Optional[str] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
online_link: Optional[str] = None
|
||||||
|
start_time: Optional[datetime] = None
|
||||||
|
end_time: Optional[datetime] = None
|
||||||
|
registration_start_date: Optional[datetime] = None
|
||||||
|
registration_end_date: Optional[datetime] = None
|
||||||
|
capacity: Optional[int] = None
|
||||||
|
price: Optional[float] = None
|
||||||
|
status: Optional[str] = None
|
||||||
|
gallery_image_ids: Optional[List[int]] = None
|
||||||
|
|
||||||
|
class RegistrationSchema(ModelSchema):
|
||||||
|
"""Schema describing a registration entry with event context."""
|
||||||
|
user: AuthorSchema
|
||||||
|
event: EventListSchema
|
||||||
|
discount_code: str | None = None
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = Registration
|
||||||
|
model_fields = [
|
||||||
|
'id',
|
||||||
|
'status',
|
||||||
|
'registered_at',
|
||||||
|
'ticket_id',
|
||||||
|
'discount_amount',
|
||||||
|
'final_price',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_discount_code(obj):
|
||||||
|
return obj.discount_code.code if obj.discount_code else None
|
||||||
|
|
||||||
|
|
||||||
|
class AdminUserSchema(Schema):
|
||||||
|
id: int
|
||||||
|
username: str
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
|
email: str
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentAdminSchema(Schema):
|
||||||
|
id: int
|
||||||
|
authority: Optional[str]
|
||||||
|
ref_id: Optional[str]
|
||||||
|
status: int
|
||||||
|
status_label: str
|
||||||
|
base_amount: int
|
||||||
|
discount_amount: int
|
||||||
|
amount: int
|
||||||
|
verified_at: Optional[datetime]
|
||||||
|
created_at: datetime
|
||||||
|
discount_code: Optional[str]
|
||||||
|
user: AdminUserSchema
|
||||||
|
|
||||||
|
@field_validator("discount_code", mode="before")
|
||||||
|
def normalize_discount_code(cls, value):
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if hasattr(value, "code"):
|
||||||
|
return value.code
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationAdminSchema(Schema):
|
||||||
|
id: int
|
||||||
|
ticket_id: UUID
|
||||||
|
status: str
|
||||||
|
status_label: str
|
||||||
|
registered_at: datetime
|
||||||
|
final_price: Optional[int]
|
||||||
|
discount_amount: Optional[int]
|
||||||
|
user: AdminUserSchema
|
||||||
|
payments: List[PaymentAdminSchema]
|
||||||
|
|
||||||
|
|
||||||
|
class EventAdminDetailSchema(EventSchema):
|
||||||
|
registrations: List[RegistrationAdminSchema] = []
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def resolve_registrations(obj):
|
||||||
|
return obj.registrations.select_related("user").prefetch_related(
|
||||||
|
"payments__discount_code"
|
||||||
|
).order_by("-registered_at")
|
||||||
|
|
||||||
|
class PaginatedRegistrationSchema(Schema):
|
||||||
|
count: int
|
||||||
|
next: Optional[str] = None
|
||||||
|
previous: Optional[str] = None
|
||||||
|
results: List[RegistrationAdminSchema]
|
||||||
|
|
||||||
|
class RegistrationStatusUpdateSchema(Schema):
|
||||||
|
status: str
|
||||||
|
|
||||||
|
class RegisterationDetailSchema(Schema):
|
||||||
|
"""Detailed registration information with associated event metadata."""
|
||||||
|
event_image: Optional[str]
|
||||||
|
event_title: str
|
||||||
|
event_type: str
|
||||||
|
ticket_id: UUID
|
||||||
|
status: str
|
||||||
|
registered_at: datetime
|
||||||
|
success_markdown: Optional[str]
|
||||||
|
|
||||||
|
class EventBriefSchema(Schema):
|
||||||
|
"""Minimal event representation used for nested responses."""
|
||||||
|
id: int
|
||||||
|
title: str
|
||||||
|
slug: str
|
||||||
|
start_date: datetime
|
||||||
|
end_date: Optional[datetime] = None
|
||||||
|
location: Optional[str] = None
|
||||||
|
price: int
|
||||||
|
absolute_image_url: Optional[str] = None
|
||||||
|
|
||||||
|
class MyEventRegistrationOut(Schema):
|
||||||
|
"""Registration information as returned to authenticated users."""
|
||||||
|
id: int
|
||||||
|
created_at: datetime
|
||||||
|
status: Literal["pending", "confirmed", "cancelled", "attended"]
|
||||||
|
event: EventBriefSchema
|
||||||
|
|
||||||
|
class RegistrationStatusOut(Schema):
|
||||||
|
is_registered: bool
|
||||||
|
|
||||||
|
|
||||||
|
class RegistrationCreateSchema(Schema):
|
||||||
|
discount_code: Optional[str] = None
|
||||||
27
backend/api/schemas/gallery.py
Normal file
27
backend/api/schemas/gallery.py
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
"""Schemas for gallery resources."""
|
||||||
|
|
||||||
|
from ninja import Schema, ModelSchema
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from api.schemas.blog import AuthorSchema
|
||||||
|
from gallery.models import Gallery
|
||||||
|
|
||||||
|
|
||||||
|
class GallerySchema(ModelSchema):
|
||||||
|
"""Serialized representation of a gallery image."""
|
||||||
|
uploaded_by: AuthorSchema
|
||||||
|
file_size_mb: float
|
||||||
|
markdown_url: str
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
model = Gallery
|
||||||
|
model_fields = ['id', 'title', 'description', 'image', 'alt_text',
|
||||||
|
'width', 'height', 'is_public', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class GalleryCreateSchema(Schema):
|
||||||
|
"""Payload for creating a gallery entry."""
|
||||||
|
title: str
|
||||||
|
description: Optional[str] = None
|
||||||
|
alt_text: Optional[str] = None
|
||||||
|
is_public: bool = True
|
||||||
35
backend/api/schemas/payments.py
Normal file
35
backend/api/schemas/payments.py
Normal file
@@ -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
|
||||||
16
backend/api/urls.py
Normal file
16
backend/api/urls.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from ninja import Router
|
||||||
|
|
||||||
|
from api.views import *
|
||||||
|
from api.views import certificates_router
|
||||||
|
|
||||||
|
router = Router()
|
||||||
|
|
||||||
|
router.add_router("auth/", auth_router, tags=["Authentication"])
|
||||||
|
router.add_router("blog/", blog_router, tags=["Blog"])
|
||||||
|
router.add_router("gallery/", gallery_router, tags=["Gallery"])
|
||||||
|
router.add_router("events/", events_router, tags=["Events"])
|
||||||
|
router.add_router("communications/", communications_router, tags=["Communications"])
|
||||||
|
router.add_router("payments/", payments_router, tags=["Payments"])
|
||||||
|
router.add_router("certificates/", certificates_router, tags=["Certificates"])
|
||||||
|
router.add_router("meta/", meta_router, tags=["Meta"])
|
||||||
|
router.add_router("/", health_router, tags=["Health"])
|
||||||
9
backend/api/views/__init__.py
Normal file
9
backend/api/views/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from api.views.auth import auth_router
|
||||||
|
from api.views.blog import blog_router
|
||||||
|
from api.views.gallery import gallery_router
|
||||||
|
from api.views.events import events_router
|
||||||
|
from api.views.certificates import certificates_router
|
||||||
|
from api.views.communications import communications_router
|
||||||
|
from api.views.payments import payments_router
|
||||||
|
from api.views.meta import meta_router
|
||||||
|
from api.views.health import health_router
|
||||||
397
backend/api/views/auth.py
Normal file
397
backend/api/views/auth.py
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
from typing import List
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import authenticate
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.core.files.storage import default_storage
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
import jwt
|
||||||
|
from ninja import Query, Router
|
||||||
|
|
||||||
|
from users.models import User, Major, University
|
||||||
|
from users.tasks import send_verification_email, send_password_reset_email
|
||||||
|
from api.authentication import create_jwt_token, create_refresh_token, jwt_auth
|
||||||
|
from api.schemas import (
|
||||||
|
UserRegistrationSchema, UserLoginSchema, UserProfileSchema,
|
||||||
|
UserUpdateSchema, TokenSchema, TokenRefreshIn, MessageSchema, ErrorSchema,
|
||||||
|
PasswordResetRequestSchema, PasswordResetConfirmSchema, UsernameCheckSchema,
|
||||||
|
UserListSchema
|
||||||
|
)
|
||||||
|
|
||||||
|
auth_router = Router()
|
||||||
|
|
||||||
|
def _get_major_from_code(code: str | None):
|
||||||
|
if not code:
|
||||||
|
return None
|
||||||
|
return Major.objects.filter(code=code, is_deleted=False).first()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_university_from_code(code: str | None):
|
||||||
|
if not code:
|
||||||
|
return None
|
||||||
|
return University.objects.filter(code=code, is_deleted=False).first()
|
||||||
|
|
||||||
|
|
||||||
|
@auth_router.post("/register", response={201: MessageSchema, 400: ErrorSchema})
|
||||||
|
def register(request, data: UserRegistrationSchema):
|
||||||
|
"""Register a new user"""
|
||||||
|
try:
|
||||||
|
if data.student_id and len(str(data.student_id)) < 10:
|
||||||
|
return 400, {"error": "Student ID must be at least 10 characters long."}
|
||||||
|
|
||||||
|
major_obj = None
|
||||||
|
if data.major:
|
||||||
|
major_obj = _get_major_from_code(data.major)
|
||||||
|
if not major_obj:
|
||||||
|
return 400, {"error": "Selected major is not recognized."}
|
||||||
|
|
||||||
|
university_obj = None
|
||||||
|
if data.university:
|
||||||
|
university_obj = _get_university_from_code(data.university)
|
||||||
|
if not university_obj:
|
||||||
|
return 400, {"error": "Selected university is not recognized."}
|
||||||
|
|
||||||
|
if User.objects.filter(username=data.username).exists():
|
||||||
|
return 400, {"error": "Username is already in use."}
|
||||||
|
|
||||||
|
if User.objects.filter(email=data.email).exists():
|
||||||
|
return 400, {"error": "Email is already registered."}
|
||||||
|
|
||||||
|
if (
|
||||||
|
data.student_id
|
||||||
|
and university_obj
|
||||||
|
and User.objects.filter(
|
||||||
|
university=university_obj, student_id=data.student_id
|
||||||
|
).exists()
|
||||||
|
):
|
||||||
|
return 400, {"error": "This student ID is already registered at that university."}
|
||||||
|
|
||||||
|
User.objects.create_user(
|
||||||
|
username=data.username,
|
||||||
|
email=data.email,
|
||||||
|
password=data.password,
|
||||||
|
student_id=data.student_id,
|
||||||
|
first_name=data.first_name or "",
|
||||||
|
last_name=data.last_name or "",
|
||||||
|
year_of_study=data.year_of_study,
|
||||||
|
major=major_obj,
|
||||||
|
university=university_obj,
|
||||||
|
)
|
||||||
|
|
||||||
|
return 201, {"message": "Registration successful. Please check your inbox to verify your email."}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return 400, {
|
||||||
|
"error": "Unable to register user.",
|
||||||
|
"details": str(e),
|
||||||
|
}
|
||||||
|
|
||||||
|
@auth_router.post("/login", response={200: TokenSchema, 401: ErrorSchema})
|
||||||
|
def login(request, data: UserLoginSchema):
|
||||||
|
"""Login user and return JWT tokens"""
|
||||||
|
user = authenticate(email=data.email, password=data.password)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
return 401, {"error": "ایمیل یا رمز عبور نادرست است."}
|
||||||
|
|
||||||
|
if not user.is_email_verified:
|
||||||
|
return 401, {"error": "برای ورود، ابتدا ایمیل خود را تأیید کنید."}
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
return 401, {"error": "حساب کاربری شما غیرفعال است."}
|
||||||
|
|
||||||
|
access_token = create_jwt_token(user)
|
||||||
|
refresh_token = create_refresh_token(user)
|
||||||
|
|
||||||
|
return 200, {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"token_type": "bearer"
|
||||||
|
}
|
||||||
|
|
||||||
|
@auth_router.post("/refresh", response={200: TokenSchema, 401: ErrorSchema})
|
||||||
|
def refresh_tokens(request, data: TokenRefreshIn):
|
||||||
|
"""Exchange a valid refresh token for a new access (and refresh) token."""
|
||||||
|
try:
|
||||||
|
payload = jwt.decode(
|
||||||
|
data.refresh_token,
|
||||||
|
settings.JWT_SECRET_KEY,
|
||||||
|
algorithms=[settings.JWT_ALGORITHM],
|
||||||
|
)
|
||||||
|
if payload.get("type") != "refresh":
|
||||||
|
return 401, {"error": "نوع توکن نامعتبر است."}
|
||||||
|
|
||||||
|
user_id = payload.get("user_id")
|
||||||
|
if not user_id:
|
||||||
|
return 401, {"error": "دادههای توکن نامعتبر است."}
|
||||||
|
|
||||||
|
user = get_object_or_404(User, id=user_id)
|
||||||
|
|
||||||
|
if not user.is_email_verified:
|
||||||
|
return 401, {"error": "برای استفاده، ابتدا ایمیل خود را تأیید کنید."}
|
||||||
|
|
||||||
|
if not user.is_active:
|
||||||
|
return 401, {"error": "حساب کاربری شما غیرفعال است."}
|
||||||
|
|
||||||
|
except jwt.ExpiredSignatureError:
|
||||||
|
return 401, {"error": "رفرشتوکن منقضی شده است."}
|
||||||
|
|
||||||
|
except jwt.InvalidTokenError:
|
||||||
|
return 401, {"error": "رفرشتوکن نامعتبر است."}
|
||||||
|
|
||||||
|
access_token = create_jwt_token(user)
|
||||||
|
refresh_token = create_refresh_token(user)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"access_token": access_token,
|
||||||
|
"refresh_token": refresh_token,
|
||||||
|
"token_type": "bearer",
|
||||||
|
}
|
||||||
|
|
||||||
|
@auth_router.get("/verify-email/{token}", response={200: MessageSchema, 400: ErrorSchema})
|
||||||
|
def verify_email(request, token: str):
|
||||||
|
"""Verify user email with token"""
|
||||||
|
try:
|
||||||
|
user = get_object_or_404(User, email_verification_token=token)
|
||||||
|
|
||||||
|
if user.is_email_verified:
|
||||||
|
return 400, {"error": "ایمیل قبلاً تأیید شده است."}
|
||||||
|
|
||||||
|
user.is_email_verified = True
|
||||||
|
user.save(update_fields=['is_email_verified'])
|
||||||
|
|
||||||
|
return 200, {"message": "ایمیل شما با موفقیت تأیید شد."}
|
||||||
|
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return 400, {"error": "توکن تأیید نامعتبر است."}
|
||||||
|
|
||||||
|
@auth_router.post("/resend-verification", response={200: MessageSchema, 400: ErrorSchema})
|
||||||
|
def resend_verification(request, email: str):
|
||||||
|
"""Resend verification email"""
|
||||||
|
try:
|
||||||
|
user = get_object_or_404(User, email=email)
|
||||||
|
|
||||||
|
if user.is_email_verified:
|
||||||
|
return 400, {"error": "ایمیل قبلاً تأیید شده است."}
|
||||||
|
|
||||||
|
# Generate new token
|
||||||
|
user.regenerate_verification_token()
|
||||||
|
user.email_verification_sent_at = timezone.now()
|
||||||
|
user.save(update_fields=['email_verification_sent_at'])
|
||||||
|
|
||||||
|
# Send verification email
|
||||||
|
verification_url = f"{settings.FRONTEND_ROOT}verify-email/{user.email_verification_token}"
|
||||||
|
send_verification_email.delay(user.id, verification_url)
|
||||||
|
|
||||||
|
return 200, {"message": "ایمیل تأیید برای شما ارسال شد."}
|
||||||
|
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return 400, {"error": "کاربر یافت نشد."}
|
||||||
|
|
||||||
|
@auth_router.get("/profile", response=UserProfileSchema, auth=jwt_auth)
|
||||||
|
def get_profile(request):
|
||||||
|
"""Get current user profile"""
|
||||||
|
return request.auth
|
||||||
|
|
||||||
|
@auth_router.put("/profile", response={200: UserProfileSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def update_profile(request, data: UserUpdateSchema):
|
||||||
|
"""Update current user profile"""
|
||||||
|
user = request.auth
|
||||||
|
payload = data.dict(exclude_unset=True)
|
||||||
|
|
||||||
|
if "major" in payload:
|
||||||
|
code = payload.pop("major")
|
||||||
|
if code:
|
||||||
|
major_obj = _get_major_from_code(code)
|
||||||
|
if not major_obj:
|
||||||
|
return 400, {"error": "UcO_ O<>OrU?UOU? O<>U^UcU+ O<>O<EFBFBD>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<EFBFBD>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}
|
||||||
|
|
||||||
|
|
||||||
299
backend/api/views/blog.py
Normal file
299
backend/api/views/blog.py
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.db.models import Q, Prefetch
|
||||||
|
|
||||||
|
from ninja import Router, Query
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from users.models import User
|
||||||
|
from blog.models import Post, Category, Tag, Comment, Like
|
||||||
|
from api.authentication import jwt_auth
|
||||||
|
from api.schemas import (
|
||||||
|
PostListSchema, PostDetailSchema, PostCreateSchema,
|
||||||
|
CategorySchema, TagSchema, CommentSchema, CommentCreateSchema,
|
||||||
|
MessageSchema, ErrorSchema
|
||||||
|
)
|
||||||
|
|
||||||
|
blog_router = Router()
|
||||||
|
|
||||||
|
# Post endpoints
|
||||||
|
@blog_router.get("/posts", response=List[PostListSchema])
|
||||||
|
def list_posts(
|
||||||
|
request,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(10, ge=1, le=50),
|
||||||
|
category: Optional[str] = None,
|
||||||
|
tag: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
featured: Optional[bool] = None,
|
||||||
|
author: Optional[str] = None
|
||||||
|
):
|
||||||
|
"""List published posts with filtering and pagination"""
|
||||||
|
queryset = Post.objects.filter(status=Post.StatusChoices.PUBLISHED).select_related(
|
||||||
|
'author', 'category'
|
||||||
|
).prefetch_related('tags')
|
||||||
|
|
||||||
|
# Apply filters
|
||||||
|
if category:
|
||||||
|
queryset = queryset.filter(category__slug=category)
|
||||||
|
|
||||||
|
if tag:
|
||||||
|
queryset = queryset.filter(tags__slug=tag)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(title__icontains=search) |
|
||||||
|
Q(content__icontains=search) |
|
||||||
|
Q(excerpt__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
if featured is not None:
|
||||||
|
queryset = queryset.filter(is_featured=featured)
|
||||||
|
|
||||||
|
if author:
|
||||||
|
queryset = queryset.filter(author__username=author)
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
posts = queryset[offset:offset + limit]
|
||||||
|
|
||||||
|
return posts
|
||||||
|
|
||||||
|
@blog_router.get("/posts/{slug}", response=PostDetailSchema)
|
||||||
|
def get_post(request, slug: str):
|
||||||
|
"""Get single post by slug"""
|
||||||
|
post = get_object_or_404(
|
||||||
|
Post.objects.select_related('author', 'category').prefetch_related('tags'),
|
||||||
|
slug=slug,
|
||||||
|
status=Post.StatusChoices.PUBLISHED
|
||||||
|
)
|
||||||
|
return post
|
||||||
|
|
||||||
|
@blog_router.post("/posts", response={201: PostDetailSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def create_post(request, data: PostCreateSchema):
|
||||||
|
"""Create a new post (committee members only)"""
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
if not (user.is_superuser or user.is_staff):
|
||||||
|
return 400, {"error": "Only committee members can create posts"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
post = Post.objects.create(
|
||||||
|
title=data.title,
|
||||||
|
content=data.content,
|
||||||
|
excerpt=data.excerpt,
|
||||||
|
author=user,
|
||||||
|
category_id=data.category_id,
|
||||||
|
status=data.status,
|
||||||
|
is_featured=data.is_featured
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.tag_ids:
|
||||||
|
post.tags.set(data.tag_ids)
|
||||||
|
|
||||||
|
return 201, post
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return 400, {"error": "Failed to create post", "details": str(e)}
|
||||||
|
|
||||||
|
@blog_router.put("/posts/{slug}", response={200: PostDetailSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def update_post(request, slug: str, data: PostCreateSchema):
|
||||||
|
"""Update a post (author or committee only)"""
|
||||||
|
user = request.auth
|
||||||
|
post = get_object_or_404(Post, slug=slug)
|
||||||
|
|
||||||
|
if not (post.author == user or user.is_superuser or user.is_staff):
|
||||||
|
return 400, {"error": "You can only edit your own posts"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for field, value in data.dict(exclude_unset=True).items():
|
||||||
|
if field == 'tag_ids':
|
||||||
|
if value:
|
||||||
|
post.tags.set(value)
|
||||||
|
elif field == 'category_id':
|
||||||
|
post.category_id = value
|
||||||
|
else:
|
||||||
|
setattr(post, field, value)
|
||||||
|
|
||||||
|
post.save()
|
||||||
|
return 200, post
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return 400, {"error": "Failed to update post", "details": str(e)}
|
||||||
|
|
||||||
|
@blog_router.delete("/posts/{slug}", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def delete_post(request, slug: str):
|
||||||
|
"""Soft delete a post owned by the requester or committee."""
|
||||||
|
user = request.auth
|
||||||
|
post = get_object_or_404(Post, slug=slug)
|
||||||
|
|
||||||
|
if not (post.author == user or user.is_superuser or user.is_staff):
|
||||||
|
return 400, {"error": "You can only delete your own posts"}
|
||||||
|
|
||||||
|
post.delete()
|
||||||
|
return 200, {"message": "Post deleted successfully"}
|
||||||
|
|
||||||
|
@blog_router.get("/deleted/posts", response=List[PostListSchema], auth=jwt_auth)
|
||||||
|
def list_deleted_posts(request):
|
||||||
|
"""List all soft-deleted posts (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
return Post.deleted_objects.all().select_related('author', 'category').prefetch_related('tags')
|
||||||
|
|
||||||
|
@blog_router.post("deleted/posts/{post_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def restore_post(request, post_id: int):
|
||||||
|
"""Restore a soft-deleted post (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
try:
|
||||||
|
post = Post.deleted_objects.get(id=post_id)
|
||||||
|
post.restore()
|
||||||
|
return 200, {"message": f"Post '{post.title}' restored successfully."}
|
||||||
|
except Post.DoesNotExist:
|
||||||
|
return 400, {"error": "Post not found or not soft-deleted."}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Comment endpoints
|
||||||
|
@blog_router.get("/posts/{slug}/comments", response=List[CommentSchema])
|
||||||
|
def list_comments(request, slug: str):
|
||||||
|
"""List approved comments for a post"""
|
||||||
|
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||||
|
|
||||||
|
comments = Comment.objects.filter(
|
||||||
|
post=post,
|
||||||
|
is_approved=True,
|
||||||
|
parent=None
|
||||||
|
).select_related('author').prefetch_related(
|
||||||
|
Prefetch(
|
||||||
|
'replies',
|
||||||
|
queryset=Comment.objects.filter(is_approved=True).select_related('author')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return comments
|
||||||
|
|
||||||
|
@blog_router.post("/posts/{slug}/comments", response={201: CommentSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def create_comment(request, slug: str, data: CommentCreateSchema):
|
||||||
|
"""Create a comment on a post"""
|
||||||
|
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
try:
|
||||||
|
comment = Comment.objects.create(
|
||||||
|
post=post,
|
||||||
|
author=user,
|
||||||
|
content=data.content,
|
||||||
|
parent_id=data.parent_id
|
||||||
|
)
|
||||||
|
|
||||||
|
return 201, comment
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return 400, {"error": "Failed to create comment", "details": str(e)}
|
||||||
|
|
||||||
|
@blog_router.get("/deleted/comments", response=List[CommentSchema], auth=jwt_auth)
|
||||||
|
def list_deleted_comments(request):
|
||||||
|
"""List all soft-deleted comments (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
return Comment.deleted_objects.all().select_related('author', 'post')
|
||||||
|
|
||||||
|
@blog_router.post("/deleted/comments/{comment_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def restore_comment(request, comment_id: int):
|
||||||
|
"""Restore a soft-deleted comment (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
try:
|
||||||
|
comment = Comment.deleted_objects.get(id=comment_id)
|
||||||
|
comment.restore()
|
||||||
|
return 200, {"message": f"Comment by {comment.author.username} restored successfully."}
|
||||||
|
except Comment.DoesNotExist:
|
||||||
|
return 400, {"error": "Comment not found or not soft-deleted."}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Like endpoints
|
||||||
|
@blog_router.post("/posts/{slug}/like", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def toggle_like(request, slug: str):
|
||||||
|
"""Toggle like on a post"""
|
||||||
|
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
like, created = Like.objects.get_or_create(post=post, user=user)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
like.delete()
|
||||||
|
return 200, {"message": "Post unliked"}
|
||||||
|
|
||||||
|
return 200, {"message": "Post liked"}
|
||||||
|
|
||||||
|
@blog_router.get("/posts/{slug}/likes", response={200: MessageSchema})
|
||||||
|
def get_likes_count(request, slug: str):
|
||||||
|
"""Get likes count for a post"""
|
||||||
|
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||||
|
count = post.likes.count()
|
||||||
|
return {"message": f"{count}"}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Category endpoints
|
||||||
|
@blog_router.get("/categories", response=List[CategorySchema])
|
||||||
|
def list_categories(request):
|
||||||
|
"""List all categories"""
|
||||||
|
return Category.objects.all()
|
||||||
|
|
||||||
|
@blog_router.get("/categories/{slug}", response=CategorySchema)
|
||||||
|
def get_category(request, slug: str):
|
||||||
|
"""Get single category by slug"""
|
||||||
|
return get_object_or_404(Category, slug=slug)
|
||||||
|
|
||||||
|
@blog_router.get("/deleted/categories", response=List[CategorySchema], auth=jwt_auth)
|
||||||
|
def list_deleted_categories(request):
|
||||||
|
"""List all soft-deleted categories (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
return Category.deleted_objects.all()
|
||||||
|
|
||||||
|
@blog_router.post("/deleted/categories/{category_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def restore_category(request, category_id: int):
|
||||||
|
"""Restore a soft-deleted category (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
try:
|
||||||
|
category = Category.deleted_objects.get(id=category_id)
|
||||||
|
category.restore()
|
||||||
|
return 200, {"message": f"Category '{category.name}' restored successfully."}
|
||||||
|
except Category.DoesNotExist:
|
||||||
|
return 400, {"error": "Category not found or not soft-deleted."}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# Tag endpoints
|
||||||
|
@blog_router.get("/tags", response=List[TagSchema])
|
||||||
|
def list_tags(request):
|
||||||
|
"""List all tags"""
|
||||||
|
return Tag.objects.all()
|
||||||
|
|
||||||
|
@blog_router.get("/tags/{slug}", response=TagSchema)
|
||||||
|
def get_tag(request, slug: str):
|
||||||
|
"""Get single tag by slug"""
|
||||||
|
return get_object_or_404(Tag, slug=slug)
|
||||||
|
|
||||||
|
@blog_router.get("/deleted/tags", response=List[TagSchema], auth=jwt_auth)
|
||||||
|
def list_deleted_tags(request):
|
||||||
|
"""List all soft-deleted tags (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
return Tag.all_objects.all()
|
||||||
|
|
||||||
|
@blog_router.post("/deleted/tags/{tag_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def restore_tag(request, tag_id: int):
|
||||||
|
"""Restore a soft-deleted tag (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
try:
|
||||||
|
tag = Tag.deleted_objects.get(id=tag_id)
|
||||||
|
tag.restore()
|
||||||
|
return 200, {"message": f"Tag '{tag.name}' restored successfully."}
|
||||||
|
except Tag.DoesNotExist:
|
||||||
|
return 400, {"error": "Tag not found or not soft-deleted."}
|
||||||
138
backend/api/views/certificates.py
Normal file
138
backend/api/views/certificates.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
|
||||||
|
from api.authentication import jwt_auth
|
||||||
|
from api.schemas.certificates import (
|
||||||
|
CertificateTemplateOut,
|
||||||
|
CertificateGenerationPayload,
|
||||||
|
CertificateGenerationResponse,
|
||||||
|
CertificateVerificationOut,
|
||||||
|
SkillSchema,
|
||||||
|
UserCertificateOut,
|
||||||
|
)
|
||||||
|
from certificates.models import CertificateTemplate, UserCertificate
|
||||||
|
|
||||||
|
|
||||||
|
certificates_router = Router(tags=["Certificates"])
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_staff(user):
|
||||||
|
if not user or not user.is_staff:
|
||||||
|
raise HttpError(403, "Only staff users can access certificate management.")
|
||||||
|
|
||||||
|
|
||||||
|
@certificates_router.get(
|
||||||
|
"templates/{int:event_id}",
|
||||||
|
response=CertificateTemplateOut,
|
||||||
|
auth=jwt_auth,
|
||||||
|
)
|
||||||
|
def get_template(request, event_id: int):
|
||||||
|
_ensure_staff(request.auth)
|
||||||
|
template = get_object_or_404(
|
||||||
|
CertificateTemplate.objects.select_related('event').prefetch_related('skills'),
|
||||||
|
event_id=event_id,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
skills = [
|
||||||
|
SkillSchema(
|
||||||
|
id=skill.id,
|
||||||
|
name=skill.name,
|
||||||
|
description=skill.description,
|
||||||
|
)
|
||||||
|
for skill in template.skills.all()
|
||||||
|
]
|
||||||
|
|
||||||
|
image_url = None
|
||||||
|
if template.image and hasattr(template.image, 'url'):
|
||||||
|
image_url = request.build_absolute_uri(template.image.url)
|
||||||
|
|
||||||
|
return CertificateTemplateOut(
|
||||||
|
id=template.id,
|
||||||
|
event_id=template.event_id,
|
||||||
|
event_title=template.event.title,
|
||||||
|
image_url=image_url,
|
||||||
|
skill_ids=list(template.skills.values_list('id', flat=True)),
|
||||||
|
skills=skills,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@certificates_router.post(
|
||||||
|
"templates/{int:event_id}/generate",
|
||||||
|
response=CertificateGenerationResponse,
|
||||||
|
auth=jwt_auth,
|
||||||
|
)
|
||||||
|
def generate_certificates(request, event_id: int, payload: CertificateGenerationPayload):
|
||||||
|
_ensure_staff(request.auth)
|
||||||
|
template = get_object_or_404(
|
||||||
|
CertificateTemplate.objects.select_related('event').prefetch_related('skills'),
|
||||||
|
event_id=event_id,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
entries = [entry.model_dump() for entry in payload.entries]
|
||||||
|
certificates = template.generate_certificates(
|
||||||
|
entries,
|
||||||
|
default_title=payload.default_title,
|
||||||
|
default_description=payload.default_description,
|
||||||
|
)
|
||||||
|
except ValidationError as exc:
|
||||||
|
raise HttpError(400, str(exc))
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for certificate in certificates:
|
||||||
|
image_url = None
|
||||||
|
if certificate.image and hasattr(certificate.image, 'url'):
|
||||||
|
image_url = request.build_absolute_uri(certificate.image.url)
|
||||||
|
|
||||||
|
result.append(
|
||||||
|
UserCertificateOut(
|
||||||
|
id=certificate.id,
|
||||||
|
user_id=certificate.user_id,
|
||||||
|
user_name=certificate.user.get_full_name() or certificate.user.email,
|
||||||
|
event_id=certificate.event_id,
|
||||||
|
title=certificate.title,
|
||||||
|
certificate_id=str(certificate.certificate_id),
|
||||||
|
certificate_code=certificate.code,
|
||||||
|
score=certificate.score,
|
||||||
|
score_label=certificate.score_label,
|
||||||
|
image_url=image_url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return CertificateGenerationResponse(certificates=result)
|
||||||
|
|
||||||
|
|
||||||
|
@certificates_router.get(
|
||||||
|
"verify/{str:certificate_code}",
|
||||||
|
response=CertificateVerificationOut,
|
||||||
|
)
|
||||||
|
def verify_certificate(request, certificate_code):
|
||||||
|
certificate = get_object_or_404(
|
||||||
|
UserCertificate.objects.select_related('event', 'user').prefetch_related('skills'),
|
||||||
|
code=certificate_code,
|
||||||
|
is_deleted=False,
|
||||||
|
)
|
||||||
|
image_url = None
|
||||||
|
if certificate.image and hasattr(certificate.image, 'url'):
|
||||||
|
image_url = request.build_absolute_uri(certificate.image.url)
|
||||||
|
|
||||||
|
return CertificateVerificationOut(
|
||||||
|
certificate_id=str(certificate.certificate_id),
|
||||||
|
certificate_code=certificate.code,
|
||||||
|
user_id=certificate.user_id,
|
||||||
|
user_name=certificate.user.get_full_name() or certificate.user.email,
|
||||||
|
event_id=certificate.event_id,
|
||||||
|
event_title=certificate.event.title,
|
||||||
|
title=certificate.title,
|
||||||
|
score=certificate.score,
|
||||||
|
score_label=certificate.score_label,
|
||||||
|
issued_at=certificate.issued_at,
|
||||||
|
expires_at=certificate.expires_at,
|
||||||
|
image_url=image_url,
|
||||||
|
skills=[skill.name for skill in certificate.skills.all()],
|
||||||
|
)
|
||||||
329
backend/api/views/communications.py
Normal file
329
backend/api/views/communications.py
Normal file
@@ -0,0 +1,329 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.db.models import Q, Count
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.pagination import paginate
|
||||||
|
from typing import List
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from communications.models import (
|
||||||
|
Announcement, NewsletterSubscription, PushNotificationDevice,
|
||||||
|
AnnouncementType, AnnouncementPriority
|
||||||
|
)
|
||||||
|
from communications.utils import (
|
||||||
|
send_announcement_email, send_newsletter_confirmation,
|
||||||
|
get_announcement_recipients
|
||||||
|
)
|
||||||
|
from communications.push_notifications import push_service
|
||||||
|
from api.schemas import (
|
||||||
|
AnnouncementSchema, AnnouncementListSchema, AnnouncementCreateSchema, AnnouncementUpdateSchema,
|
||||||
|
NewsletterSubscriptionSchema, NewsletterSubscribeSchema, NewsletterUnsubscribeSchema,
|
||||||
|
PushDeviceSchema, PushDeviceCreateSchema, PushDeviceUpdateSchema,
|
||||||
|
PushNotificationSchema, MessageResponseSchema,
|
||||||
|
AnnouncementStatsSchema, NewsletterStatsSchema
|
||||||
|
)
|
||||||
|
from api.authentication import jwt_auth
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
communications_router = Router()
|
||||||
|
|
||||||
|
# Announcement endpoints
|
||||||
|
@communications_router.get("/announcements/", response=List[AnnouncementListSchema])
|
||||||
|
@paginate
|
||||||
|
def list_announcements(request, published_only: bool = True):
|
||||||
|
"""List announcements"""
|
||||||
|
queryset = Announcement.objects.select_related('author').filter(is_deleted=False)
|
||||||
|
|
||||||
|
if published_only:
|
||||||
|
queryset = queryset.filter(is_published=True, publish_date__lte=timezone.now())
|
||||||
|
|
||||||
|
return queryset.order_by('-created_at')
|
||||||
|
|
||||||
|
@communications_router.get("/announcements/{announcement_id}/", response=AnnouncementSchema)
|
||||||
|
def get_announcement(request, announcement_id: int):
|
||||||
|
"""Get single announcement"""
|
||||||
|
announcement = get_object_or_404(
|
||||||
|
Announcement.objects.select_related('author').filter(is_deleted=False),
|
||||||
|
id=announcement_id
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if published or user has permission
|
||||||
|
if not announcement.is_published:
|
||||||
|
# Only allow access to unpublished announcements for staff/committee
|
||||||
|
if not hasattr(request, 'auth') or not request.auth:
|
||||||
|
return {"error": "Announcement not found"}, 404
|
||||||
|
|
||||||
|
user = request.auth
|
||||||
|
if not (user.is_staff or user.is_committee):
|
||||||
|
return {"error": "Announcement not found"}, 404
|
||||||
|
|
||||||
|
return announcement
|
||||||
|
|
||||||
|
@communications_router.post("/announcements/", response=AnnouncementSchema, auth=jwt_auth)
|
||||||
|
def create_announcement(request, payload: AnnouncementCreateSchema):
|
||||||
|
"""Create new announcement (committee/staff only)"""
|
||||||
|
user = request.auth
|
||||||
|
if not (user.is_staff or user.is_committee):
|
||||||
|
return {"error": "Permission denied"}, 403
|
||||||
|
|
||||||
|
announcement = Announcement.objects.create(
|
||||||
|
author=user,
|
||||||
|
**payload.dict()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send notifications if requested and published
|
||||||
|
if announcement.is_published and announcement.publish_date <= timezone.now():
|
||||||
|
if announcement.send_email:
|
||||||
|
recipients = get_announcement_recipients(announcement)
|
||||||
|
if recipients:
|
||||||
|
send_announcement_email(announcement, recipients)
|
||||||
|
announcement.email_sent = True
|
||||||
|
|
||||||
|
if announcement.send_push:
|
||||||
|
push_service.send_announcement_notification(announcement)
|
||||||
|
announcement.push_sent = True
|
||||||
|
|
||||||
|
announcement.save()
|
||||||
|
|
||||||
|
return announcement
|
||||||
|
|
||||||
|
@communications_router.put("/announcements/{announcement_id}/", response=AnnouncementSchema, auth=jwt_auth)
|
||||||
|
def update_announcement(request, announcement_id: int, payload: AnnouncementUpdateSchema):
|
||||||
|
"""Update announcement (author/committee/staff only)"""
|
||||||
|
user = request.auth
|
||||||
|
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if not (user.is_staff or user.is_committee or announcement.author == user):
|
||||||
|
return {"error": "Permission denied"}, 403
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
for field, value in payload.dict(exclude_unset=True).items():
|
||||||
|
setattr(announcement, field, value)
|
||||||
|
|
||||||
|
announcement.save()
|
||||||
|
|
||||||
|
# Send notifications if newly published
|
||||||
|
if (announcement.is_published and announcement.publish_date <= timezone.now() and
|
||||||
|
not announcement.email_sent and announcement.send_email):
|
||||||
|
recipients = get_announcement_recipients(announcement)
|
||||||
|
if recipients:
|
||||||
|
send_announcement_email(announcement, recipients)
|
||||||
|
announcement.email_sent = True
|
||||||
|
announcement.save()
|
||||||
|
|
||||||
|
if (announcement.is_published and announcement.publish_date <= timezone.now() and
|
||||||
|
not announcement.push_sent and announcement.send_push):
|
||||||
|
push_service.send_announcement_notification(announcement)
|
||||||
|
announcement.push_sent = True
|
||||||
|
announcement.save()
|
||||||
|
|
||||||
|
return announcement
|
||||||
|
|
||||||
|
@communications_router.delete("/announcements/{announcement_id}/", response=MessageResponseSchema, auth=jwt_auth)
|
||||||
|
def delete_announcement(request, announcement_id: int):
|
||||||
|
"""Delete announcement (author/committee/staff only)"""
|
||||||
|
user = request.auth
|
||||||
|
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
|
||||||
|
|
||||||
|
# Check permissions
|
||||||
|
if not (user.is_staff or user.is_committee or announcement.author == user):
|
||||||
|
return {"error": "Permission denied"}, 403
|
||||||
|
|
||||||
|
announcement.soft_delete()
|
||||||
|
return {"message": "Announcement deleted successfully"}
|
||||||
|
|
||||||
|
@communications_router.get("/announcements/stats/", response=AnnouncementStatsSchema, auth=jwt_auth)
|
||||||
|
def get_announcement_stats(request):
|
||||||
|
"""Get announcement statistics (committee/staff only)"""
|
||||||
|
user = request.auth
|
||||||
|
if not (user.is_staff or user.is_committee):
|
||||||
|
return {"error": "Permission denied"}, 403
|
||||||
|
|
||||||
|
stats = Announcement.objects.filter(is_deleted=False).aggregate(
|
||||||
|
total_announcements=Count('id'),
|
||||||
|
published_announcements=Count('id', filter=Q(is_published=True)),
|
||||||
|
draft_announcements=Count('id', filter=Q(is_published=False)),
|
||||||
|
urgent_announcements=Count('id', filter=Q(priority='urgent')),
|
||||||
|
email_sent_count=Count('id', filter=Q(email_sent=True)),
|
||||||
|
push_sent_count=Count('id', filter=Q(push_sent=True))
|
||||||
|
)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# Newsletter endpoints
|
||||||
|
@communications_router.post("/newsletter/subscribe/", response=MessageResponseSchema)
|
||||||
|
def subscribe_newsletter(request, payload: NewsletterSubscribeSchema):
|
||||||
|
"""Subscribe to newsletter"""
|
||||||
|
try:
|
||||||
|
subscription, created = NewsletterSubscription.objects.get_or_create(
|
||||||
|
email=payload.email,
|
||||||
|
defaults={
|
||||||
|
'subscribed_categories': payload.subscribed_categories,
|
||||||
|
'is_active': True
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created and not subscription.is_active:
|
||||||
|
subscription.is_active = True
|
||||||
|
subscription.subscribed_categories = payload.subscribed_categories
|
||||||
|
subscription.save()
|
||||||
|
|
||||||
|
# Send confirmation email
|
||||||
|
send_newsletter_confirmation(subscription)
|
||||||
|
|
||||||
|
message = (
|
||||||
|
"عضویت در خبرنامه با موفقیت انجام شد! لطفاً برای تأیید، ایمیل خود را بررسی کنید."
|
||||||
|
if created
|
||||||
|
else "اشتراک خبرنامه بهروزرسانی شد!"
|
||||||
|
)
|
||||||
|
return {"message": message}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Newsletter subscription failed: {str(e)}")
|
||||||
|
return {"message": "Subscription failed", "success": False}, 400
|
||||||
|
|
||||||
|
@communications_router.post("/newsletter/unsubscribe/", response=MessageResponseSchema)
|
||||||
|
def unsubscribe_newsletter(request, payload: NewsletterUnsubscribeSchema):
|
||||||
|
"""Unsubscribe from newsletter"""
|
||||||
|
try:
|
||||||
|
subscription = NewsletterSubscription.objects.get(email=payload.email)
|
||||||
|
subscription.is_active = False
|
||||||
|
subscription.save()
|
||||||
|
return {"message": "Successfully unsubscribed from newsletter"}
|
||||||
|
except NewsletterSubscription.DoesNotExist:
|
||||||
|
return {"message": "Email not found in subscription list"}, 404
|
||||||
|
|
||||||
|
@communications_router.get("/newsletter/confirm/{token}/", response=MessageResponseSchema)
|
||||||
|
def confirm_newsletter_subscription(request, token: str):
|
||||||
|
"""Confirm newsletter subscription"""
|
||||||
|
try:
|
||||||
|
subscription = NewsletterSubscription.objects.get(confirmation_token=token)
|
||||||
|
subscription.confirmed_at = timezone.now()
|
||||||
|
subscription.is_active = True
|
||||||
|
subscription.save()
|
||||||
|
return {"message": "Newsletter subscription confirmed successfully!"}
|
||||||
|
except NewsletterSubscription.DoesNotExist:
|
||||||
|
return {"message": "Invalid confirmation token"}, 400
|
||||||
|
|
||||||
|
@communications_router.get("/newsletter/unsubscribe/{token}/", response=MessageResponseSchema)
|
||||||
|
def unsubscribe_newsletter_token(request, token: str):
|
||||||
|
"""Unsubscribe using token from email"""
|
||||||
|
try:
|
||||||
|
subscription = NewsletterSubscription.objects.get(unsubscribe_token=token)
|
||||||
|
subscription.is_active = False
|
||||||
|
subscription.save()
|
||||||
|
return {"message": "Successfully unsubscribed from newsletter"}
|
||||||
|
except NewsletterSubscription.DoesNotExist:
|
||||||
|
return {"message": "Invalid unsubscribe token"}, 400
|
||||||
|
|
||||||
|
@communications_router.get("/newsletter/subscriptions/", response=List[NewsletterSubscriptionSchema], auth=jwt_auth)
|
||||||
|
@paginate
|
||||||
|
def list_newsletter_subscriptions(request):
|
||||||
|
"""List newsletter subscriptions (committee/staff only)"""
|
||||||
|
user = request.auth
|
||||||
|
if not (user.is_staff or user.is_committee):
|
||||||
|
return {"error": "Permission denied"}, 403
|
||||||
|
|
||||||
|
return NewsletterSubscription.objects.select_related('user').filter(is_deleted=False).order_by('-created_at')
|
||||||
|
|
||||||
|
@communications_router.get("/newsletter/stats/", response=NewsletterStatsSchema, auth=jwt_auth)
|
||||||
|
def get_newsletter_stats(request):
|
||||||
|
"""Get newsletter statistics (committee/staff only)"""
|
||||||
|
user = request.auth
|
||||||
|
if not (user.is_staff or user.is_committee):
|
||||||
|
return {"error": "Permission denied"}, 403
|
||||||
|
|
||||||
|
stats = NewsletterSubscription.objects.filter(is_deleted=False).aggregate(
|
||||||
|
total_subscriptions=Count('id'),
|
||||||
|
active_subscriptions=Count('id', filter=Q(is_active=True)),
|
||||||
|
confirmed_subscriptions=Count('id', filter=Q(confirmed_at__isnull=False)),
|
||||||
|
recent_subscriptions=Count('id', filter=Q(created_at__gte=timezone.now() - timezone.timedelta(days=30)))
|
||||||
|
)
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# Push notification endpoints
|
||||||
|
@communications_router.post("/push-devices/", response=PushDeviceSchema, auth=jwt_auth)
|
||||||
|
def register_push_device(request, payload: PushDeviceCreateSchema):
|
||||||
|
"""Register push notification device"""
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
device, created = PushNotificationDevice.objects.get_or_create(
|
||||||
|
user=user,
|
||||||
|
device_token=payload.device_token,
|
||||||
|
defaults={'device_type': payload.device_type, 'is_active': True}
|
||||||
|
)
|
||||||
|
|
||||||
|
if not created:
|
||||||
|
device.is_active = True
|
||||||
|
device.device_type = payload.device_type
|
||||||
|
device.save()
|
||||||
|
|
||||||
|
return device
|
||||||
|
|
||||||
|
@communications_router.delete("/push-devices/", response=MessageResponseSchema, auth=jwt_auth)
|
||||||
|
def unregister_push_device(request, device_token: str):
|
||||||
|
"""Unregister push notification device"""
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
try:
|
||||||
|
device = PushNotificationDevice.objects.get(user=user, device_token=device_token)
|
||||||
|
device.delete()
|
||||||
|
return {"message": "Device unregistered successfully"}
|
||||||
|
except PushNotificationDevice.DoesNotExist:
|
||||||
|
return {"message": "Device not found"}, 404
|
||||||
|
|
||||||
|
@communications_router.get("/push-devices/", response=List[PushDeviceSchema], auth=jwt_auth)
|
||||||
|
def list_user_push_devices(request):
|
||||||
|
"""List user's push notification devices"""
|
||||||
|
user = request.auth
|
||||||
|
return PushNotificationDevice.objects.filter(user=user, is_deleted=False).order_by('-created_at')
|
||||||
|
|
||||||
|
@communications_router.put("/push-devices/{device_id}/", response=PushDeviceSchema, auth=jwt_auth)
|
||||||
|
def update_push_device(request, device_id: int, payload: PushDeviceUpdateSchema):
|
||||||
|
"""Update push notification device"""
|
||||||
|
user = request.auth
|
||||||
|
device = get_object_or_404(PushNotificationDevice, id=device_id, user=user, is_deleted=False)
|
||||||
|
|
||||||
|
device.is_active = payload.is_active
|
||||||
|
device.save()
|
||||||
|
|
||||||
|
return device
|
||||||
|
|
||||||
|
@communications_router.post("/push-notifications/send/", response=MessageResponseSchema, auth=jwt_auth)
|
||||||
|
def send_push_notification(request, payload: PushNotificationSchema):
|
||||||
|
"""Send push notification (committee/staff only)"""
|
||||||
|
user = request.auth
|
||||||
|
if not (user.is_staff or user.is_committee):
|
||||||
|
return {"error": "Permission denied"}, 403
|
||||||
|
|
||||||
|
# Get target users
|
||||||
|
users = []
|
||||||
|
if payload.target_audience == 'all':
|
||||||
|
users = User.objects.filter(is_active=True)
|
||||||
|
elif payload.target_audience == 'members':
|
||||||
|
users = User.objects.filter(is_member=True, is_active=True)
|
||||||
|
elif payload.target_audience == 'committee':
|
||||||
|
users = User.objects.filter(is_committee=True, is_active=True)
|
||||||
|
|
||||||
|
# Send notifications
|
||||||
|
total_sent = push_service.send_to_multiple_users(
|
||||||
|
users, payload.title, payload.body, payload.data
|
||||||
|
)
|
||||||
|
|
||||||
|
return {"message": f"Push notification sent to {total_sent} devices"}
|
||||||
|
|
||||||
|
# Utility endpoints
|
||||||
|
@communications_router.get("/announcement-types/", response=List[dict])
|
||||||
|
def get_announcement_types(request):
|
||||||
|
"""Get available announcement types"""
|
||||||
|
return [{"value": choice[0], "label": choice[1]} for choice in AnnouncementType.choices]
|
||||||
|
|
||||||
|
@communications_router.get("/announcement-priorities/", response=List[dict])
|
||||||
|
def get_announcement_priorities(request):
|
||||||
|
"""Get available announcement priorities"""
|
||||||
|
return [{"value": choice[0], "label": choice[1]} for choice in AnnouncementPriority.choices]
|
||||||
371
backend/api/views/events.py
Normal file
371
backend/api/views/events.py
Normal file
@@ -0,0 +1,371 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.db.models import Q, Case, When, IntegerField
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from ninja import Router, Query
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
from typing import List, Optional
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from api.authentication import jwt_auth
|
||||||
|
from events.models import Event, Registration
|
||||||
|
from payments.models import DiscountCode
|
||||||
|
from api.schemas import (
|
||||||
|
EventSchema,
|
||||||
|
EventCreateSchema,
|
||||||
|
EventUpdateSchema,
|
||||||
|
EventListSchema,
|
||||||
|
RegistrationSchema,
|
||||||
|
RegistrationStatusUpdateSchema,
|
||||||
|
RegisterationDetailSchema,
|
||||||
|
MyEventRegistrationOut,
|
||||||
|
RegistrationStatusOut,
|
||||||
|
EventBriefSchema,
|
||||||
|
EventAdminDetailSchema,
|
||||||
|
PaginatedRegistrationSchema,
|
||||||
|
MessageSchema,
|
||||||
|
ErrorSchema,
|
||||||
|
RegistrationCreateSchema,
|
||||||
|
)
|
||||||
|
|
||||||
|
events_router = Router()
|
||||||
|
|
||||||
|
# Event endpoints
|
||||||
|
@events_router.get("/", response=List[EventListSchema])
|
||||||
|
def list_events(
|
||||||
|
request,
|
||||||
|
# status: Optional[str] = None,
|
||||||
|
status: Optional[List[str]] = Query(None),
|
||||||
|
event_type: Optional[str] = None,
|
||||||
|
search: Optional[str] = None,
|
||||||
|
limit: int = 20,
|
||||||
|
offset: int = 0
|
||||||
|
):
|
||||||
|
"""List events with filtering and pagination"""
|
||||||
|
queryset = Event.objects.filter(is_deleted=False).prefetch_related('gallery_images')
|
||||||
|
|
||||||
|
if status:
|
||||||
|
if "," in status:
|
||||||
|
parts = [s.strip() for s in status.split(",") if s.strip()]
|
||||||
|
queryset = queryset.filter(status__in=parts)
|
||||||
|
else:
|
||||||
|
queryset = queryset.filter(status__in=status)
|
||||||
|
if event_type:
|
||||||
|
queryset = queryset.filter(event_type=event_type)
|
||||||
|
if search:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
Q(title__icontains=search) | Q(description__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
queryset = queryset.annotate(
|
||||||
|
published_first=Case(
|
||||||
|
When(status='published', then=0),
|
||||||
|
default=1,
|
||||||
|
output_field=IntegerField()
|
||||||
|
)
|
||||||
|
).order_by('published_first', '-start_time', '-id')
|
||||||
|
|
||||||
|
events = queryset[offset:offset + limit]
|
||||||
|
return events
|
||||||
|
|
||||||
|
@events_router.get("/{int:event_id}", response=EventSchema)
|
||||||
|
def get_event(request, event_id: int):
|
||||||
|
"""Get event details by ID"""
|
||||||
|
event = get_object_or_404(
|
||||||
|
Event.objects.prefetch_related('gallery_images'),
|
||||||
|
id=event_id,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
|
||||||
|
@events_router.get("/slug/{str:slug}", response=EventSchema)
|
||||||
|
def get_event_by_slug(request, slug: str):
|
||||||
|
"""Get event details by slug"""
|
||||||
|
event = get_object_or_404(
|
||||||
|
Event.objects.prefetch_related('gallery_images'),
|
||||||
|
slug=slug,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
return event
|
||||||
|
|
||||||
|
@events_router.post("/", response=EventSchema)
|
||||||
|
def create_event(request, payload: EventCreateSchema):
|
||||||
|
"""Create a new event"""
|
||||||
|
gallery_image_ids = payload.dict().pop('gallery_image_ids', [])
|
||||||
|
event = Event.objects.create(**payload.dict(exclude={'gallery_image_ids'}))
|
||||||
|
|
||||||
|
if gallery_image_ids:
|
||||||
|
event.gallery_images.set(gallery_image_ids)
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
@events_router.put("/{int:event_id}", response=EventSchema)
|
||||||
|
def update_event(request, event_id: int, payload: EventUpdateSchema):
|
||||||
|
"""Update an existing event"""
|
||||||
|
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||||
|
|
||||||
|
update_data = payload.dict(exclude_unset=True)
|
||||||
|
gallery_image_ids = update_data.pop('gallery_image_ids', None)
|
||||||
|
|
||||||
|
for attr, value in update_data.items():
|
||||||
|
setattr(event, attr, value)
|
||||||
|
|
||||||
|
if 'title' in update_data:
|
||||||
|
event.slug = slugify(event.title)
|
||||||
|
|
||||||
|
event.save()
|
||||||
|
|
||||||
|
if gallery_image_ids is not None:
|
||||||
|
event.gallery_images.set(gallery_image_ids)
|
||||||
|
|
||||||
|
return event
|
||||||
|
|
||||||
|
@events_router.delete("/{int:event_id}", response=MessageSchema)
|
||||||
|
def delete_event(request, event_id: int):
|
||||||
|
"""Soft delete an event"""
|
||||||
|
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||||
|
event.delete()
|
||||||
|
return {"message": "Event deleted successfully"}
|
||||||
|
|
||||||
|
# Registration endpoints
|
||||||
|
@events_router.get("/{int:event_id}/registrations", response=List[RegistrationSchema])
|
||||||
|
def list_event_registrations(request, event_id: int, limit: int = 20, offset: int = 0):
|
||||||
|
"""List registrations for a specific event"""
|
||||||
|
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||||
|
queryset = event.registrations.filter(is_deleted=False).select_related('user')
|
||||||
|
|
||||||
|
registrations = queryset[offset:offset + limit]
|
||||||
|
return registrations
|
||||||
|
|
||||||
|
|
||||||
|
@events_router.get("/{int:event_id}/admin-registrations", response={200: PaginatedRegistrationSchema, 403: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def list_event_registrations_admin(
|
||||||
|
request,
|
||||||
|
event_id: int,
|
||||||
|
status: Optional[List[str]] = Query(None),
|
||||||
|
university: Optional[str] = Query(None),
|
||||||
|
major: Optional[str] = Query(None),
|
||||||
|
search: Optional[str] = Query(None),
|
||||||
|
limit: int = Query(20, ge=1, le=200),
|
||||||
|
offset: int = Query(0, ge=0),
|
||||||
|
):
|
||||||
|
"""List registrations with filters for admin dashboard"""
|
||||||
|
user = request.auth
|
||||||
|
if not (user.is_staff or user.is_superuser):
|
||||||
|
return 403, {"error": "اجازه دسترسی ندارید."}
|
||||||
|
|
||||||
|
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||||
|
qs = (
|
||||||
|
event.registrations.filter(is_deleted=False)
|
||||||
|
.select_related("user")
|
||||||
|
.prefetch_related("payments__discount_code")
|
||||||
|
.order_by("-registered_at")
|
||||||
|
)
|
||||||
|
|
||||||
|
status_values = status or request.GET.getlist('status')
|
||||||
|
if status_values:
|
||||||
|
qs = qs.filter(status__in=status_values)
|
||||||
|
|
||||||
|
if university:
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(user__university__code__icontains=university)
|
||||||
|
| Q(user__university__name__icontains=university)
|
||||||
|
)
|
||||||
|
|
||||||
|
if major:
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(user__major__code__icontains=major)
|
||||||
|
| Q(user__major__name__icontains=major)
|
||||||
|
)
|
||||||
|
|
||||||
|
if search:
|
||||||
|
qs = qs.filter(
|
||||||
|
Q(user__username__icontains=search)
|
||||||
|
| Q(user__email__icontains=search)
|
||||||
|
| Q(user__first_name__icontains=search)
|
||||||
|
| Q(user__last_name__icontains=search)
|
||||||
|
)
|
||||||
|
|
||||||
|
total = qs.count()
|
||||||
|
results = qs[offset : offset + limit]
|
||||||
|
|
||||||
|
return PaginatedRegistrationSchema(count=total, next=None, previous=None, results=list(results))
|
||||||
|
|
||||||
|
@events_router.post(
|
||||||
|
"/{int:event_id}/register",
|
||||||
|
response=RegistrationSchema,
|
||||||
|
auth=jwt_auth,
|
||||||
|
)
|
||||||
|
def register_for_event(
|
||||||
|
request,
|
||||||
|
event_id: int,
|
||||||
|
payload: RegistrationCreateSchema | None = None,
|
||||||
|
):
|
||||||
|
"""Register current user for an event"""
|
||||||
|
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
if Registration.objects.filter(event=event, user=user, status=Registration.StatusChoices.CONFIRMED).exists():
|
||||||
|
raise HttpError(400, "شما قبلا در این ایونت ثبتنام کردهاید.")
|
||||||
|
|
||||||
|
if event.registration_end_date and event.registration_end_date < timezone.now():
|
||||||
|
raise HttpError(400, "مهلت ثبتنام به پایان رسیدهاست")
|
||||||
|
|
||||||
|
if event.registration_start_date and event.registration_start_date > timezone.now():
|
||||||
|
raise HttpError(400, "زمان ثبتنام هنوز آغاز نشده است")
|
||||||
|
|
||||||
|
if not event.has_available_slots:
|
||||||
|
raise HttpError(400, "ظرفیت شرکتکنندگان تکمیل است")
|
||||||
|
|
||||||
|
# Create or get existing registration
|
||||||
|
discount_code = None
|
||||||
|
if payload and payload.discount_code:
|
||||||
|
discount_code = payload.discount_code
|
||||||
|
elif request.GET.get("discount_code"):
|
||||||
|
discount_code = request.GET.get("discount_code")
|
||||||
|
|
||||||
|
registration, created = Registration.objects.get_or_create(
|
||||||
|
event=event,
|
||||||
|
user=user,
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
defaults={"final_price": event.price},
|
||||||
|
)
|
||||||
|
|
||||||
|
if registration.status == Registration.StatusChoices.CONFIRMED:
|
||||||
|
return HttpError(400, "شما قبلا در این ایونت ثبتنام کردهاید")
|
||||||
|
|
||||||
|
if registration.status == Registration.StatusChoices.CANCELLED:
|
||||||
|
registration = Registration.objects.create(
|
||||||
|
event=event,
|
||||||
|
user=user,
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
final_price=event.price,
|
||||||
|
)
|
||||||
|
elif not created and registration.final_price is None:
|
||||||
|
registration.final_price = event.price
|
||||||
|
registration.save(update_fields=["final_price"])
|
||||||
|
|
||||||
|
applied_code = None
|
||||||
|
discount_amount = 0
|
||||||
|
final_price = event.price
|
||||||
|
fields_to_update = []
|
||||||
|
|
||||||
|
if discount_code:
|
||||||
|
applied_code = DiscountCode.objects.filter(
|
||||||
|
code=discount_code,
|
||||||
|
applicable_events=event,
|
||||||
|
is_active=True,
|
||||||
|
).first()
|
||||||
|
if not applied_code:
|
||||||
|
raise HttpError(400, "UcO_ O<>OrU?UOU? U.O1O<31>O\"O<EFBFBD> U+UOO3O<33>")
|
||||||
|
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
|
||||||
127
backend/api/views/gallery.py
Normal file
127
backend/api/views/gallery.py
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
from django.shortcuts import get_object_or_404
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
|
||||||
|
from ninja import Router, Query, File, UploadedFile
|
||||||
|
from typing import List
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from gallery.models import Gallery
|
||||||
|
from gallery.tasks import process_uploaded_image
|
||||||
|
from api.authentication import jwt_auth
|
||||||
|
from api.schemas import GallerySchema, GalleryCreateSchema, MessageSchema, ErrorSchema
|
||||||
|
|
||||||
|
gallery_router = Router()
|
||||||
|
|
||||||
|
@gallery_router.get("/images", response=List[GallerySchema])
|
||||||
|
def list_gallery_images(
|
||||||
|
request,
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
limit: int = Query(20, ge=1, le=50),
|
||||||
|
public_only: bool = Query(True)
|
||||||
|
):
|
||||||
|
"""List gallery images"""
|
||||||
|
queryset = Gallery.objects.select_related('uploaded_by')
|
||||||
|
|
||||||
|
if public_only:
|
||||||
|
queryset = queryset.filter(is_public=True)
|
||||||
|
|
||||||
|
# Pagination
|
||||||
|
offset = (page - 1) * limit
|
||||||
|
images = queryset[offset:offset + limit]
|
||||||
|
|
||||||
|
return images
|
||||||
|
|
||||||
|
@gallery_router.get("/images/{image_id}", response=GallerySchema)
|
||||||
|
def get_gallery_image(request, image_id: int):
|
||||||
|
"""Get single gallery image"""
|
||||||
|
image = get_object_or_404(Gallery, id=image_id, is_public=True)
|
||||||
|
return image
|
||||||
|
|
||||||
|
@gallery_router.post("/images", response={201: GallerySchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def upload_image(request, file: UploadedFile = File(...), data: GalleryCreateSchema = None):
|
||||||
|
"""Upload image to gallery (committee members only)"""
|
||||||
|
user = request.auth
|
||||||
|
|
||||||
|
if not (user.is_superuser or user.is_staff):
|
||||||
|
return 400, {"error": "Only committee members can upload images"}
|
||||||
|
|
||||||
|
# Validate file type
|
||||||
|
if not file.content_type.startswith('image/'):
|
||||||
|
return 400, {"error": "File must be an image"}
|
||||||
|
|
||||||
|
# Validate file size (10MB max)
|
||||||
|
if file.size > 10 * 1024 * 1024:
|
||||||
|
return 400, {"error": "File size must be less than 10MB"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Create gallery item
|
||||||
|
gallery_item = Gallery.objects.create(
|
||||||
|
title=data.title if data else file.name,
|
||||||
|
description=data.description if data else "",
|
||||||
|
uploaded_by=user,
|
||||||
|
alt_text=data.alt_text if data else "",
|
||||||
|
is_public=data.is_public if data else True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Save image
|
||||||
|
filename = f"gallery/{uuid.uuid4().hex}.{file.name.split('.')[-1]}"
|
||||||
|
gallery_item.image.save(filename, ContentFile(file.read()))
|
||||||
|
|
||||||
|
# Process image asynchronously
|
||||||
|
process_uploaded_image.delay(gallery_item.id)
|
||||||
|
|
||||||
|
return 201, gallery_item
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return 400, {"error": "Failed to upload image", "details": str(e)}
|
||||||
|
|
||||||
|
@gallery_router.put("/images/{image_id}", response={200: GallerySchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def update_image(request, image_id: int, data: GalleryCreateSchema):
|
||||||
|
"""Update gallery image metadata"""
|
||||||
|
user = request.auth
|
||||||
|
image = get_object_or_404(Gallery, id=image_id)
|
||||||
|
|
||||||
|
if not (image.uploaded_by == user or user.is_superuser or user.is_staff):
|
||||||
|
return 400, {"error": "You can only edit your own images"}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for field, value in data.dict(exclude_unset=True).items():
|
||||||
|
setattr(image, field, value)
|
||||||
|
|
||||||
|
image.save()
|
||||||
|
return 200, image
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return 400, {"error": "Failed to update image", "details": str(e)}
|
||||||
|
|
||||||
|
@gallery_router.delete("/images/{image_id}", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def delete_image(request, image_id: int):
|
||||||
|
"""Soft delete a gallery image owned by the requester or committee."""
|
||||||
|
user = request.auth
|
||||||
|
image = get_object_or_404(Gallery, id=image_id)
|
||||||
|
|
||||||
|
if not (image.uploaded_by == user or user.is_superuser or user.is_staff):
|
||||||
|
return 400, {"error": "You can only delete your own images"}
|
||||||
|
|
||||||
|
image.delete()
|
||||||
|
return 200, {"message": "Image deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
|
@gallery_router.get("/deleted/images", response=List[GallerySchema], auth=jwt_auth)
|
||||||
|
def list_deleted_gallery_images(request):
|
||||||
|
"""List all soft-deleted gallery images (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
return Gallery.deleted_objects.all().select_related('uploaded_by')
|
||||||
|
|
||||||
|
@gallery_router.post("/deleted/images/{image_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||||
|
def restore_gallery_image(request, image_id: int):
|
||||||
|
"""Restore a soft-deleted gallery image (Admin/Committee only)"""
|
||||||
|
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||||
|
return 403, {"error": "Permission denied"}
|
||||||
|
try:
|
||||||
|
image = Gallery.deleted_objects.get(id=image_id)
|
||||||
|
image.restore()
|
||||||
|
return 200, {"message": f"Gallery image '{image.title}' restored successfully."}
|
||||||
|
except Gallery.DoesNotExist:
|
||||||
|
return 400, {"error": "Gallery image not found or not soft-deleted."}
|
||||||
15
backend/api/views/health.py
Normal file
15
backend/api/views/health.py
Normal file
@@ -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
|
||||||
15
backend/api/views/meta.py
Normal file
15
backend/api/views/meta.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from ninja import Router
|
||||||
|
|
||||||
|
from users.models import Major, University
|
||||||
|
|
||||||
|
meta_router = Router(tags=['meta'])
|
||||||
|
|
||||||
|
@meta_router.get("/majors")
|
||||||
|
def list_majors(request):
|
||||||
|
majors = Major.objects.filter(is_deleted=False, is_active=True).order_by("name")
|
||||||
|
return [{"id": m.id, "code": m.code, "label": m.name} for m in majors]
|
||||||
|
|
||||||
|
@meta_router.get("/universities")
|
||||||
|
def list_universities(request):
|
||||||
|
universities = University.objects.filter(is_deleted=False, is_active=True).order_by("name")
|
||||||
|
return [{"id": u.id, "code": u.code, "label": u.name} for u in universities]
|
||||||
240
backend/api/views/payments.py
Normal file
240
backend/api/views/payments.py
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
from django.shortcuts import redirect, get_object_or_404
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from ninja import Router
|
||||||
|
from ninja.errors import HttpError
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from payments.models import Payment, DiscountCode
|
||||||
|
from events.models import Event, Registration
|
||||||
|
from api.authentication import jwt_auth
|
||||||
|
from api.schemas.payments import CouponVerifyIn, CouponVerifyOut, CreatePaymentIn, CreatePaymentOut, PaymentDetailOut
|
||||||
|
|
||||||
|
payments_router = Router(tags=["Payments"])
|
||||||
|
|
||||||
|
|
||||||
|
@payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth)
|
||||||
|
def create_payment(request, payload: CreatePaymentIn):
|
||||||
|
event = get_object_or_404(Event, pk=payload.event_id)
|
||||||
|
|
||||||
|
if Payment.objects.filter(status=Payment.OrderStatusChoices.PAID, user=request.auth, event=event).exists():
|
||||||
|
raise HttpError(400, "You have already registered in this event")
|
||||||
|
|
||||||
|
registration = (
|
||||||
|
Registration.objects.filter(event=event, user=request.auth, is_deleted=False)
|
||||||
|
.order_by("-registered_at")
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not registration or registration.status == Registration.StatusChoices.CANCELLED:
|
||||||
|
registration = Registration.objects.create(
|
||||||
|
event=event,
|
||||||
|
user=request.auth,
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
final_price=event.price,
|
||||||
|
)
|
||||||
|
elif registration.final_price is None:
|
||||||
|
registration.final_price = event.price
|
||||||
|
registration.save(update_fields=["final_price"])
|
||||||
|
|
||||||
|
discount_code = None
|
||||||
|
discount_amount = 0
|
||||||
|
final_amount = event.price
|
||||||
|
|
||||||
|
if payload.discount_code:
|
||||||
|
discount_code = DiscountCode.objects.filter(code=payload.discount_code, applicable_events=event, is_active=True).first()
|
||||||
|
|
||||||
|
if discount_code:
|
||||||
|
final_amount, discount_amount = discount_code.calculate_discount(event, request.auth)
|
||||||
|
|
||||||
|
registration_updates = []
|
||||||
|
if discount_code and registration.discount_code_id != discount_code.id:
|
||||||
|
registration.discount_code = discount_code
|
||||||
|
registration_updates.append("discount_code")
|
||||||
|
if registration.discount_amount != discount_amount:
|
||||||
|
registration.discount_amount = discount_amount
|
||||||
|
registration_updates.append("discount_amount")
|
||||||
|
if registration.final_price != final_amount:
|
||||||
|
registration.final_price = final_amount
|
||||||
|
registration_updates.append("final_price")
|
||||||
|
|
||||||
|
if final_amount == 0:
|
||||||
|
if registration.status != Registration.StatusChoices.CONFIRMED:
|
||||||
|
registration.status = Registration.StatusChoices.CONFIRMED
|
||||||
|
registration_updates.append("status")
|
||||||
|
if registration_updates:
|
||||||
|
registration.save(update_fields=list(set(registration_updates)))
|
||||||
|
else:
|
||||||
|
registration.save(update_fields=["status"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"start_pay_url": None,
|
||||||
|
"authority": None,
|
||||||
|
"base_amount": event.price,
|
||||||
|
"discount_amount": discount_amount if discount_amount else 0,
|
||||||
|
"amount": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
if registration_updates:
|
||||||
|
registration.save(update_fields=list(set(registration_updates)))
|
||||||
|
|
||||||
|
pay = Payment.objects.create(
|
||||||
|
user=request.auth,
|
||||||
|
event=event,
|
||||||
|
base_amount=event.price,
|
||||||
|
discount_code=discount_code,
|
||||||
|
discount_amount=discount_amount,
|
||||||
|
amount=final_amount,
|
||||||
|
status=Payment.OrderStatusChoices.INIT,
|
||||||
|
registration=registration,
|
||||||
|
)
|
||||||
|
|
||||||
|
callback_url = getattr(settings, "ZARINPAL_CALLBACK_URL", "http://localhost:8000/api/payments/callback")
|
||||||
|
body = {
|
||||||
|
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
|
||||||
|
"amount": final_amount,
|
||||||
|
"callback_url": callback_url,
|
||||||
|
"description": payload.description,
|
||||||
|
"metadata": {
|
||||||
|
k: v for k, v in {
|
||||||
|
"mobile": payload.mobile,
|
||||||
|
"email": payload.email,
|
||||||
|
"event_id": event.id,
|
||||||
|
"user_id": request.auth.id,
|
||||||
|
"payment_id": pay.id,
|
||||||
|
"discount_code": discount_code.code if discount_code else None,
|
||||||
|
}.items() if v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(
|
||||||
|
settings.ZARINPAL_REQUEST_URL,
|
||||||
|
json=body,
|
||||||
|
headers={"accept":"application/json","content-type":"application/json"},
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
jd = response.json()
|
||||||
|
except Exception as e:
|
||||||
|
pay.delete()
|
||||||
|
raise HttpError(502, f"Gateway request failed: {e}")
|
||||||
|
|
||||||
|
code = (jd.get("data") or {}).get("code")
|
||||||
|
if code != 100:
|
||||||
|
pay.delete()
|
||||||
|
raise HttpError(502, f"Zarinpal error: {jd.get('errors') or jd}")
|
||||||
|
|
||||||
|
authority = jd["data"]["authority"]
|
||||||
|
pay.authority = authority
|
||||||
|
pay.status = Payment.OrderStatusChoices.PENDING
|
||||||
|
pay.save(update_fields=["authority","status"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"start_pay_url": f"{settings.ZARINPAL_STARTPAY}{authority}",
|
||||||
|
"authority": authority,
|
||||||
|
"base_amount": event.price,
|
||||||
|
"discount_amount": discount_amount if discount_amount else 0,
|
||||||
|
"amount": final_amount,
|
||||||
|
}
|
||||||
|
|
||||||
|
@payments_router.get("callback")
|
||||||
|
def callback(request, Authority: str | None = None, Status: str | None = None):
|
||||||
|
if not Authority:
|
||||||
|
raise HttpError(400, "Missing Authority")
|
||||||
|
|
||||||
|
pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first()
|
||||||
|
if not pay:
|
||||||
|
raise HttpError(404, "Payment not found")
|
||||||
|
|
||||||
|
if Status != "OK":
|
||||||
|
pay.status = Payment.OrderStatusChoices.CANCELED
|
||||||
|
pay.save(update_fields=["status"])
|
||||||
|
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
||||||
|
|
||||||
|
verify_body = {
|
||||||
|
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
|
||||||
|
"amount": pay.amount,
|
||||||
|
"authority": Authority,
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
vresp = requests.post(
|
||||||
|
settings.ZARINPAL_VERIFY_URL,
|
||||||
|
json=verify_body,
|
||||||
|
headers={"accept":"application/json","content-type":"application/json"},
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
vjd = vresp.json()
|
||||||
|
except Exception:
|
||||||
|
pay.status = Payment.OrderStatusChoices.FAILED
|
||||||
|
pay.save(update_fields=["status"])
|
||||||
|
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
||||||
|
|
||||||
|
vcode = (vjd.get("data") or {}).get("code")
|
||||||
|
if vcode in (100, 101):
|
||||||
|
data = vjd.get("data") or {}
|
||||||
|
pay.status = Payment.OrderStatusChoices.PAID
|
||||||
|
pay.ref_id = data.get("ref_id")
|
||||||
|
pay.card_pan = data.get("card_pan")
|
||||||
|
pay.card_hash = data.get("card_hash")
|
||||||
|
pay.verified_at = timezone.now()
|
||||||
|
pay.save(update_fields=["status", "ref_id", "card_pan", "card_hash", "verified_at"])
|
||||||
|
|
||||||
|
registration = pay.registration or Registration.objects.filter(
|
||||||
|
user=pay.user,
|
||||||
|
event=pay.event,
|
||||||
|
status=Registration.StatusChoices.PENDING,
|
||||||
|
).first()
|
||||||
|
if registration:
|
||||||
|
registration.status = Registration.StatusChoices.CONFIRMED
|
||||||
|
updates = ["status"]
|
||||||
|
if registration.final_price is None:
|
||||||
|
registration.final_price = pay.amount
|
||||||
|
updates.append("final_price")
|
||||||
|
registration.save(update_fields=updates)
|
||||||
|
|
||||||
|
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=success&event_id={pay.event_id}&ref_id={pay.ref_id}")
|
||||||
|
|
||||||
|
pay.status = Payment.OrderStatusChoices.FAILED
|
||||||
|
pay.save(update_fields=["status"])
|
||||||
|
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
||||||
|
|
||||||
|
@payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut)
|
||||||
|
def payment_by_ref(request, ref_id: str):
|
||||||
|
pay = get_object_or_404(Payment.objects.select_related("event"), ref_id=ref_id)
|
||||||
|
ev = pay.event
|
||||||
|
return {
|
||||||
|
"ref_id": pay.ref_id,
|
||||||
|
"authority": pay.authority,
|
||||||
|
"base_amount": pay.base_amount,
|
||||||
|
"discount_amount": pay.discount_amount or 0,
|
||||||
|
"amount": pay.amount,
|
||||||
|
"status": pay.get_status_display(),
|
||||||
|
"verified_at": pay.verified_at.isoformat() if pay.verified_at else None,
|
||||||
|
"event": {
|
||||||
|
"id": ev.id,
|
||||||
|
"title": ev.title,
|
||||||
|
"slug": ev.slug,
|
||||||
|
"image_url": request.build_absolute_uri(ev.featured_image.url) if ev.featured_image else None,
|
||||||
|
"success_markdown": ev.registration_success_markdown,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
@payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth)
|
||||||
|
def check_coupon(request, payload: CouponVerifyIn):
|
||||||
|
event = get_object_or_404(Event, id=payload.event_id)
|
||||||
|
code = payload.code
|
||||||
|
|
||||||
|
if not code:
|
||||||
|
raise HttpError(404, "لطفا کد تخفیف را وارد کنید")
|
||||||
|
|
||||||
|
try:
|
||||||
|
c = DiscountCode.objects.get(code=code, applicable_events=event, is_active=True)
|
||||||
|
final_price, disc = c.calculate_discount(event, request.auth)
|
||||||
|
return {
|
||||||
|
"discount_amount": disc,
|
||||||
|
"final_price": final_price,
|
||||||
|
}
|
||||||
|
|
||||||
|
except DiscountCode.DoesNotExist:
|
||||||
|
raise HttpError(404, "کد تخفیف معتبر نیست")
|
||||||
159
backend/blog/admin.py
Normal file
159
backend/blog/admin.py
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
from simplemde.widgets import SimpleMDEEditor
|
||||||
|
|
||||||
|
from blog.models import Category, Tag, Post, Comment, Like
|
||||||
|
from blog.resources import PostResource, CategoryResource
|
||||||
|
from utils.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||||
|
|
||||||
|
@admin.register(Category)
|
||||||
|
class CategoryAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
resource_class = CategoryResource
|
||||||
|
list_display = ('name', 'slug', 'created_at', 'is_deleted')
|
||||||
|
list_filter = ('created_at', 'is_deleted', SoftDeleteListFilter)
|
||||||
|
search_fields = ('name', 'description')
|
||||||
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
readonly_fields = ('created_at', 'updated_at', 'deleted_at')
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Content', {
|
||||||
|
'fields': ('name', 'slug', 'description')
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('created_at', 'updated_at')
|
||||||
|
}),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
actions = BaseModelAdmin.actions + ['restore_categories']
|
||||||
|
|
||||||
|
def restore_categories(self, request, queryset):
|
||||||
|
for category in queryset:
|
||||||
|
category.restore()
|
||||||
|
self.message_user(request, f"Restored {queryset.count()} categories.")
|
||||||
|
restore_categories.short_description = "Restore selected categories"
|
||||||
|
|
||||||
|
@admin.register(Tag)
|
||||||
|
class TagAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
list_display = ('name', 'slug', 'created_at', 'is_deleted')
|
||||||
|
list_filter = ('created_at', 'is_deleted', SoftDeleteListFilter)
|
||||||
|
search_fields = ('name',)
|
||||||
|
prepopulated_fields = {'slug': ('name',)}
|
||||||
|
readonly_fields = ('created_at', 'updated_at', 'deleted_at')
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Content', {
|
||||||
|
'fields': ('name', 'slug')
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('created_at', 'updated_at')
|
||||||
|
}),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PostAdminForm(forms.ModelForm):
|
||||||
|
content = forms.CharField(widget=SimpleMDEEditor())
|
||||||
|
excerpt = forms.CharField(widget=SimpleMDEEditor())
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Post
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Post)
|
||||||
|
class PostAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
form = PostAdminForm
|
||||||
|
resource_class = PostResource
|
||||||
|
list_display = ('title', 'author', 'status', 'category', 'is_featured', 'published_at', 'created_at')
|
||||||
|
list_filter = ('status', 'is_featured', 'category', 'tags', 'created_at', 'published_at', SoftDeleteListFilter)
|
||||||
|
search_fields = ('title', 'content', 'author__username')
|
||||||
|
prepopulated_fields = {'slug': ('title',)}
|
||||||
|
filter_horizontal = ('tags',)
|
||||||
|
date_hierarchy = 'published_at'
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Content', {
|
||||||
|
'fields': ('title', 'slug', 'content', 'excerpt', 'featured_image')
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('author', 'category', 'tags', 'status', 'is_featured', 'published_at')
|
||||||
|
}),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly_fields = ('deleted_at',)
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + ['make_published', 'make_draft', 'make_featured', 'restore_posts']
|
||||||
|
|
||||||
|
def make_published(self, request, queryset):
|
||||||
|
queryset.update(status='published')
|
||||||
|
self.message_user(request, f"Published {queryset.count()} posts.")
|
||||||
|
make_published.short_description = "Mark selected posts as published"
|
||||||
|
|
||||||
|
def make_draft(self, request, queryset):
|
||||||
|
queryset.update(status='draft')
|
||||||
|
self.message_user(request, f"Marked {queryset.count()} posts as draft.")
|
||||||
|
make_draft.short_description = "Mark selected posts as draft"
|
||||||
|
|
||||||
|
def make_featured(self, request, queryset):
|
||||||
|
queryset.update(is_featured=True)
|
||||||
|
self.message_user(request, f"Featured {queryset.count()} posts.")
|
||||||
|
make_featured.short_description = "Mark selected posts as featured"
|
||||||
|
|
||||||
|
def restore_posts(self, request, queryset):
|
||||||
|
for post in queryset:
|
||||||
|
post.restore()
|
||||||
|
self.message_user(request, f"Restored {queryset.count()} posts.")
|
||||||
|
restore_posts.short_description = "Restore selected posts"
|
||||||
|
|
||||||
|
@admin.register(Comment)
|
||||||
|
class CommentAdmin(BaseModelAdmin):
|
||||||
|
list_display = ('author', 'post', 'content_preview', 'is_approved', 'created_at')
|
||||||
|
list_filter = ('is_approved', 'created_at', 'post', SoftDeleteListFilter)
|
||||||
|
search_fields = ('content', 'author__username', 'author__last_name', 'author__first_name', 'post__title')
|
||||||
|
readonly_fields = ('content_preview', 'created_at', 'updated_at', 'deleted_at')
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Content', {
|
||||||
|
'fields': ('post', 'author', 'content')
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('is_approved', 'created_at', 'updated_at')
|
||||||
|
}),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
actions = BaseModelAdmin.actions + ['approve_comments', 'disapprove_comments']
|
||||||
|
|
||||||
|
def content_preview(self, obj):
|
||||||
|
return obj.content[:50] + '...' if len(obj.content) > 50 else obj.content
|
||||||
|
content_preview.short_description = 'Content Preview'
|
||||||
|
|
||||||
|
def approve_comments(self, request, queryset):
|
||||||
|
queryset.update(is_approved=True)
|
||||||
|
self.message_user(request, f"Approved {queryset.count()} comments.")
|
||||||
|
approve_comments.short_description = "Approve selected comments"
|
||||||
|
|
||||||
|
def disapprove_comments(self, request, queryset):
|
||||||
|
queryset.update(is_approved=False)
|
||||||
|
self.message_user(request, f"Disapproved {queryset.count()} comments.")
|
||||||
|
disapprove_comments.short_description = "Disapprove selected comments"
|
||||||
|
|
||||||
|
@admin.register(Like)
|
||||||
|
class LikeAdmin(BaseModelAdmin):
|
||||||
|
list_display = ('user', 'post', 'created_at')
|
||||||
|
list_filter = ('created_at', 'post')
|
||||||
|
search_fields = ('user__username', 'post__title')
|
||||||
5
backend/blog/apps.py
Normal file
5
backend/blog/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class BlogConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'blog'
|
||||||
672
backend/blog/fixtures/blog.json
Normal file
672
backend/blog/fixtures/blog.json
Normal file
@@ -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 <View style={styles.container}>\n <Text style={styles.title}>سلام دنیا!</Text>\n </View>\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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
89
backend/blog/migrations/0001_initial.py
Normal file
89
backend/blog/migrations/0001_initial.py
Normal file
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
78
backend/blog/migrations/0002_initial.py
Normal file
78
backend/blog/migrations/0002_initial.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/blog/migrations/__init__.py
Normal file
0
backend/blog/migrations/__init__.py
Normal file
137
backend/blog/models.py
Normal file
137
backend/blog/models.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils.text import slugify
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
import markdown
|
||||||
|
|
||||||
|
from utils.models import BaseModel
|
||||||
|
|
||||||
|
class Category(BaseModel):
|
||||||
|
name = models.CharField(max_length=100, unique=True)
|
||||||
|
slug = models.SlugField(max_length=100, unique=True, blank=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name_plural = "Categories"
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
class Tag(BaseModel):
|
||||||
|
name = models.CharField(max_length=50, unique=True)
|
||||||
|
slug = models.SlugField(max_length=50, unique=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.name)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
class Post(BaseModel):
|
||||||
|
class StatusChoices(models.TextChoices):
|
||||||
|
DRAFT = 'draft', 'Draft'
|
||||||
|
PUBLISHED = 'published', 'Published'
|
||||||
|
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
slug = models.SlugField(max_length=200, unique=True, blank=True)
|
||||||
|
content = models.TextField(help_text="Content in Markdown format")
|
||||||
|
excerpt = models.TextField(max_length=300, blank=True)
|
||||||
|
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='posts')
|
||||||
|
featured_image = models.ImageField(upload_to='blog/featured/', null=True, blank=True)
|
||||||
|
status = models.CharField(max_length=10, choices=StatusChoices.choices, default=StatusChoices.DRAFT)
|
||||||
|
published_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
category = models.ForeignKey(Category, on_delete=models.SET_NULL, null=True, blank=True, related_name='posts')
|
||||||
|
tags = models.ManyToManyField(Tag, blank=True, related_name='posts')
|
||||||
|
is_featured = models.BooleanField(default=False)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['status', 'published_at']),
|
||||||
|
models.Index(fields=['is_featured']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.title)
|
||||||
|
|
||||||
|
# Auto-generate excerpt if not provided
|
||||||
|
if not self.excerpt and self.content:
|
||||||
|
# Convert markdown to plain text for excerpt
|
||||||
|
plain_text = markdown.markdown(self.content, extensions=['markdown.extensions.extra'])
|
||||||
|
# Remove HTML tags and truncate
|
||||||
|
import re
|
||||||
|
plain_text = re.sub('<[^<]+?>', '', plain_text)
|
||||||
|
self.excerpt = plain_text[:297] + '...' if len(plain_text) > 300 else plain_text
|
||||||
|
|
||||||
|
if self.status == Post.StatusChoices.PUBLISHED and not self.published_at:
|
||||||
|
self.published_at = timezone.now()
|
||||||
|
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_html(self):
|
||||||
|
"""Convert markdown content to HTML"""
|
||||||
|
return markdown.markdown(
|
||||||
|
self.content,
|
||||||
|
extensions=[
|
||||||
|
'markdown.extensions.extra',
|
||||||
|
'markdown.extensions.codehilite',
|
||||||
|
'markdown.extensions.toc',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def reading_time(self):
|
||||||
|
"""Estimate reading time in minutes assuming 200 words per minute."""
|
||||||
|
word_count = len(self.content.split())
|
||||||
|
return max(1, word_count // 200)
|
||||||
|
|
||||||
|
class Comment(BaseModel):
|
||||||
|
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='comments')
|
||||||
|
author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='comments')
|
||||||
|
content = models.TextField()
|
||||||
|
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='replies')
|
||||||
|
is_approved = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['created_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['post', 'is_approved']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'Comment by {self.author.username} on {self.post.title}'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_reply(self):
|
||||||
|
return self.parent is not None
|
||||||
|
|
||||||
|
class Like(models.Model):
|
||||||
|
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name='likes')
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='likes')
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ['post', 'user']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['post']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.user.username} likes {self.post.title}'
|
||||||
32
backend/blog/resources.py
Normal file
32
backend/blog/resources.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from import_export import resources, fields
|
||||||
|
from import_export.widgets import ForeignKeyWidget, ManyToManyWidget
|
||||||
|
|
||||||
|
from users.models import User
|
||||||
|
from blog.models import Post, Category, Tag
|
||||||
|
|
||||||
|
class CategoryResource(resources.ModelResource):
|
||||||
|
class Meta:
|
||||||
|
model = Category
|
||||||
|
fields = ('id', 'name', 'slug', 'description', 'created_at')
|
||||||
|
|
||||||
|
class PostResource(resources.ModelResource):
|
||||||
|
author = fields.Field(
|
||||||
|
column_name='author',
|
||||||
|
attribute='author',
|
||||||
|
widget=ForeignKeyWidget(User, 'username')
|
||||||
|
)
|
||||||
|
category = fields.Field(
|
||||||
|
column_name='category',
|
||||||
|
attribute='category',
|
||||||
|
widget=ForeignKeyWidget(Category, 'name')
|
||||||
|
)
|
||||||
|
tags = fields.Field(
|
||||||
|
column_name='tags',
|
||||||
|
attribute='tags',
|
||||||
|
widget=ManyToManyWidget(Tag, field='name', separator='|')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Post
|
||||||
|
fields = ('id', 'title', 'slug', 'content', 'excerpt', 'author',
|
||||||
|
'category', 'tags', 'status', 'is_featured', 'published_at', 'created_at')
|
||||||
BIN
backend/celerybeat-schedule
Normal file
BIN
backend/celerybeat-schedule
Normal file
Binary file not shown.
1
backend/certificates/__init__.py
Normal file
1
backend/certificates/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
""""""
|
||||||
24
backend/certificates/admin.py
Normal file
24
backend/certificates/admin.py
Normal file
@@ -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',)
|
||||||
6
backend/certificates/apps.py
Normal file
6
backend/certificates/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CertificatesConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'certificates'
|
||||||
80
backend/certificates/migrations/0001_initial.py
Normal file
80
backend/certificates/migrations/0001_initial.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Generated by Django 4.2.13 on 2025-11-18 09:47
|
||||||
|
|
||||||
|
import certificates.models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
import django.utils.timezone
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('events', '0012_alter_eventemaillog_kind'),
|
||||||
|
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Skill',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('name', models.CharField(max_length=120, unique=True)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['name'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CertificateTemplate',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('image', models.ImageField(upload_to='certificates/templates/')),
|
||||||
|
('event', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='certificate_template', to='events.event')),
|
||||||
|
('skills', models.ManyToManyField(blank=True, help_text='Skills covered by this event.', related_name='certificate_templates', to='certificates.skill')),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'Certificate template',
|
||||||
|
'verbose_name_plural': 'Certificate templates',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='UserCertificate',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('is_deleted', models.BooleanField(default=False)),
|
||||||
|
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('certificate_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||||
|
('code', models.CharField(default=certificates.models._generate_certificate_code, editable=False, max_length=10, unique=True)),
|
||||||
|
('title', models.CharField(max_length=255)),
|
||||||
|
('description', models.TextField(blank=True)),
|
||||||
|
('score', models.PositiveSmallIntegerField(default=0)),
|
||||||
|
('issued_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||||
|
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('image', models.ImageField(blank=True, null=True, upload_to='certificates/generated/')),
|
||||||
|
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='user_certificates', to='events.event')),
|
||||||
|
('skills', models.ManyToManyField(blank=True, help_text='Skills demonstrated on this certificate.', related_name='user_certificates', to='certificates.skill')),
|
||||||
|
('template', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='awarded_certificates', to='certificates.certificatetemplate')),
|
||||||
|
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='certificates', to=settings.AUTH_USER_MODEL)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'ordering': ['-issued_at'],
|
||||||
|
'indexes': [models.Index(fields=['user', 'event'], name='certificate_user_id_61901c_idx'), models.Index(fields=['event', 'score'], name='certificate_event_i_25b8ab_idx')],
|
||||||
|
'unique_together': {('user', 'event')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
1
backend/certificates/migrations/__init__.py
Normal file
1
backend/certificates/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
""""""
|
||||||
316
backend/certificates/models.py
Normal file
316
backend/certificates/models.py
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
from io import BytesIO
|
||||||
|
from typing import Optional, Sequence
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.files.base import ContentFile
|
||||||
|
from django.db import models
|
||||||
|
from django.utils import timezone
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from events.models import Registration
|
||||||
|
from users.models import User
|
||||||
|
from utils.models import BaseModel
|
||||||
|
|
||||||
|
SHORT_CERTIFICATE_CODE_LENGTH = 10
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_certificate_code() -> str:
|
||||||
|
return uuid4().hex[:SHORT_CERTIFICATE_CODE_LENGTH]
|
||||||
|
|
||||||
|
|
||||||
|
class Skill(BaseModel):
|
||||||
|
name = models.CharField(max_length=120, unique=True)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['name']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.name
|
||||||
|
|
||||||
|
|
||||||
|
class CertificateTemplate(BaseModel):
|
||||||
|
event = models.OneToOneField(
|
||||||
|
'events.Event',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='certificate_template',
|
||||||
|
)
|
||||||
|
image = models.ImageField(upload_to='certificates/templates/')
|
||||||
|
skills = models.ManyToManyField(
|
||||||
|
Skill,
|
||||||
|
blank=True,
|
||||||
|
related_name='certificate_templates',
|
||||||
|
help_text='Skills covered by this event.',
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Certificate template'
|
||||||
|
verbose_name_plural = 'Certificate templates'
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.event.title} template'
|
||||||
|
|
||||||
|
def _validate_score(self, score: Optional[int]) -> int:
|
||||||
|
"""Normalize score values and ensure they stay within 0-100."""
|
||||||
|
if score is None:
|
||||||
|
raise ValidationError("Score is required")
|
||||||
|
try:
|
||||||
|
normalized = int(score)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
raise ValidationError("Score must be an integer between 0 and 100")
|
||||||
|
if normalized < 0 or normalized > 100:
|
||||||
|
raise ValidationError("Score must be between 0 and 100")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _resolve_skill_ids(self, skill_ids: Optional[Sequence[int]]) -> list[int]:
|
||||||
|
"""Return a cleaned list of skill IDs, defaulting to the template skills."""
|
||||||
|
if skill_ids is None:
|
||||||
|
return list(self.skills.values_list('id', flat=True))
|
||||||
|
|
||||||
|
normalized = []
|
||||||
|
seen = set()
|
||||||
|
for skill_id in skill_ids:
|
||||||
|
if skill_id is None:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
skill_int = int(skill_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
if skill_int not in seen:
|
||||||
|
seen.add(skill_int)
|
||||||
|
normalized.append(skill_int)
|
||||||
|
|
||||||
|
if not normalized:
|
||||||
|
return []
|
||||||
|
|
||||||
|
existing = set(Skill.objects.filter(id__in=normalized).values_list('id', flat=True))
|
||||||
|
missing = set(normalized) - existing
|
||||||
|
if missing:
|
||||||
|
raise ValidationError(f"Skills not found: {', '.join(str(mid) for mid in sorted(missing))}")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
def _ensure_user_registration(self, user: User) -> Registration:
|
||||||
|
"""Require that the user has a confirmed or attended registration for the event."""
|
||||||
|
registration = Registration.objects.filter(
|
||||||
|
event=self.event,
|
||||||
|
user=user,
|
||||||
|
status__in=[
|
||||||
|
Registration.StatusChoices.CONFIRMED,
|
||||||
|
Registration.StatusChoices.ATTENDED,
|
||||||
|
],
|
||||||
|
is_deleted=False,
|
||||||
|
).order_by('-registered_at').first()
|
||||||
|
if not registration:
|
||||||
|
raise ValidationError("User must have a confirmed or attended registration for this event.")
|
||||||
|
return registration
|
||||||
|
|
||||||
|
def _load_font(self, size: int = 48):
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype("arial.ttf", size)
|
||||||
|
except Exception:
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
def _render_certificate_image(self, certificate: 'UserCertificate') -> None:
|
||||||
|
"""Overlay user-specific text on the template image and attach it to the certificate."""
|
||||||
|
if not self.image:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
template_path = self.image.path
|
||||||
|
except (AttributeError, ValueError):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
base_image = Image.open(template_path).convert("RGB")
|
||||||
|
except FileNotFoundError:
|
||||||
|
return
|
||||||
|
|
||||||
|
draw = ImageDraw.Draw(base_image)
|
||||||
|
font = self._load_font(size=48)
|
||||||
|
width, height = base_image.size
|
||||||
|
lines = [
|
||||||
|
certificate.user.get_full_name() or certificate.user.email,
|
||||||
|
self.event.title,
|
||||||
|
f"Score: {certificate.score} ({certificate.score_label})",
|
||||||
|
timezone.localtime(certificate.issued_at).strftime('%Y-%m-%d'),
|
||||||
|
]
|
||||||
|
margin = 40
|
||||||
|
total_height = 0
|
||||||
|
measurements = []
|
||||||
|
for line in lines:
|
||||||
|
bbox = draw.textbbox((0, 0), line, font=font)
|
||||||
|
line_height = bbox[3] - bbox[1]
|
||||||
|
line_width = bbox[2] - bbox[0]
|
||||||
|
measurements.append((line, line_width, line_height))
|
||||||
|
total_height += line_height + 10
|
||||||
|
y = height - margin - total_height
|
||||||
|
for line, line_width, line_height in measurements:
|
||||||
|
x = (width - line_width) / 2
|
||||||
|
draw.text((x, y), line, fill='black', font=font)
|
||||||
|
y += line_height + 10
|
||||||
|
|
||||||
|
buffer = BytesIO()
|
||||||
|
base_image.save(buffer, format='PNG')
|
||||||
|
buffer.seek(0)
|
||||||
|
filename = f"{self.event.slug}_{certificate.user_id}_{uuid4().hex}.png"
|
||||||
|
certificate.image.save(filename, ContentFile(buffer.read()), save=False)
|
||||||
|
certificate.save(update_fields=['image'])
|
||||||
|
|
||||||
|
def award_certificate(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user: User,
|
||||||
|
title: str,
|
||||||
|
description: str = '',
|
||||||
|
score: Optional[int] = None,
|
||||||
|
skill_ids: Optional[Sequence[int]] = None,
|
||||||
|
issued_at=None,
|
||||||
|
expires_at=None,
|
||||||
|
) -> 'UserCertificate':
|
||||||
|
"""
|
||||||
|
Create or update the certificate for a single user.
|
||||||
|
"""
|
||||||
|
self._ensure_user_registration(user)
|
||||||
|
resolved_score = self._validate_score(score)
|
||||||
|
resolved_skills = self._resolve_skill_ids(skill_ids)
|
||||||
|
issued_at = issued_at or timezone.now()
|
||||||
|
title = title or f"{self.event.title} Certificate"
|
||||||
|
description = description or ''
|
||||||
|
|
||||||
|
certificate, _ = UserCertificate.objects.update_or_create(
|
||||||
|
user=user,
|
||||||
|
event=self.event,
|
||||||
|
defaults={
|
||||||
|
'template': self,
|
||||||
|
'title': title,
|
||||||
|
'description': description,
|
||||||
|
'score': resolved_score,
|
||||||
|
'issued_at': issued_at,
|
||||||
|
'expires_at': expires_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
certificate.skills.set(resolved_skills)
|
||||||
|
self._render_certificate_image(certificate)
|
||||||
|
return certificate
|
||||||
|
|
||||||
|
def generate_certificates(
|
||||||
|
self,
|
||||||
|
entries: Sequence[dict],
|
||||||
|
*,
|
||||||
|
default_title: Optional[str] = None,
|
||||||
|
default_description: Optional[str] = None,
|
||||||
|
) -> list['UserCertificate']:
|
||||||
|
"""
|
||||||
|
Create certificates for a batch of users.
|
||||||
|
Entries expect dicts with at least `user_id` and `score`.
|
||||||
|
"""
|
||||||
|
if not entries:
|
||||||
|
raise ValidationError("Entries payload must contain at least one item.")
|
||||||
|
|
||||||
|
user_ids = {entry.get('user_id') for entry in entries if entry.get('user_id') is not None}
|
||||||
|
if not user_ids:
|
||||||
|
raise ValidationError("No valid user IDs were provided.")
|
||||||
|
|
||||||
|
users = {user.id: user for user in User.objects.filter(id__in=user_ids)}
|
||||||
|
missing = user_ids - users.keys()
|
||||||
|
if missing:
|
||||||
|
raise ValidationError(f"Users not found: {', '.join(str(uid) for uid in sorted(missing))}")
|
||||||
|
|
||||||
|
certificates = []
|
||||||
|
for entry in entries:
|
||||||
|
user = users.get(entry.get('user_id'))
|
||||||
|
if not user:
|
||||||
|
continue
|
||||||
|
certificate = self.award_certificate(
|
||||||
|
user=user,
|
||||||
|
title=entry.get('title') or default_title or f"{self.event.title} Certificate",
|
||||||
|
description=entry.get('description') or default_description or '',
|
||||||
|
score=entry.get('score'),
|
||||||
|
skill_ids=entry.get('skill_ids'),
|
||||||
|
issued_at=entry.get('issued_at'),
|
||||||
|
expires_at=entry.get('expires_at'),
|
||||||
|
)
|
||||||
|
certificates.append(certificate)
|
||||||
|
return certificates
|
||||||
|
|
||||||
|
|
||||||
|
class UserCertificate(BaseModel):
|
||||||
|
SCORE_RANGES = [
|
||||||
|
(0, 24, 'Fair'),
|
||||||
|
(25, 49, 'Good'),
|
||||||
|
(50, 74, 'Very Good'),
|
||||||
|
(75, 100, 'Perfect'),
|
||||||
|
]
|
||||||
|
|
||||||
|
user = models.ForeignKey(
|
||||||
|
settings.AUTH_USER_MODEL,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='certificates',
|
||||||
|
)
|
||||||
|
event = models.ForeignKey(
|
||||||
|
'events.Event',
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='user_certificates',
|
||||||
|
)
|
||||||
|
template = models.ForeignKey(
|
||||||
|
CertificateTemplate,
|
||||||
|
on_delete=models.PROTECT,
|
||||||
|
related_name='awarded_certificates',
|
||||||
|
)
|
||||||
|
certificate_id = models.UUIDField(default=uuid4, unique=True, editable=False)
|
||||||
|
code = models.CharField(
|
||||||
|
max_length=SHORT_CERTIFICATE_CODE_LENGTH,
|
||||||
|
unique=True,
|
||||||
|
editable=False,
|
||||||
|
default=_generate_certificate_code,
|
||||||
|
)
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
score = models.PositiveSmallIntegerField(default=0)
|
||||||
|
issued_at = models.DateTimeField(default=timezone.now)
|
||||||
|
expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
image = models.ImageField(
|
||||||
|
upload_to='certificates/generated/',
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
)
|
||||||
|
skills = models.ManyToManyField(
|
||||||
|
Skill,
|
||||||
|
blank=True,
|
||||||
|
related_name='user_certificates',
|
||||||
|
help_text='Skills demonstrated on this certificate.',
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ('user', 'event')
|
||||||
|
ordering = ['-issued_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['user', 'event']),
|
||||||
|
models.Index(fields=['event', 'score']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f'{self.user} - {self.title} ({self.certificate_id})'
|
||||||
|
|
||||||
|
@property
|
||||||
|
def score_label(self) -> str:
|
||||||
|
for lower, upper, label in self.SCORE_RANGES:
|
||||||
|
if lower <= self.score <= upper:
|
||||||
|
return label
|
||||||
|
return 'Unknown'
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _make_unique_code() -> str:
|
||||||
|
"""Generate a short certificate code without collisions."""
|
||||||
|
for _ in range(5):
|
||||||
|
candidate = _generate_certificate_code()
|
||||||
|
if not UserCertificate.objects.filter(code=candidate).exists():
|
||||||
|
return candidate
|
||||||
|
raise RuntimeError("Unable to generate a unique certificate code.")
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.code or UserCertificate.objects.filter(code=self.code).exclude(pk=self.pk).exists():
|
||||||
|
self.code = self._make_unique_code()
|
||||||
|
super().save(*args, **kwargs)
|
||||||
122
backend/communications/admin.py
Normal file
122
backend/communications/admin.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
from django import forms
|
||||||
|
from django.contrib import admin
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from simplemde.widgets import SimpleMDEEditor
|
||||||
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
|
||||||
|
from utils.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||||
|
from communications.models import Announcement, NewsletterSubscription, PushNotificationDevice
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementAdminForm(forms.ModelForm):
|
||||||
|
content = forms.CharField(
|
||||||
|
widget=SimpleMDEEditor(),
|
||||||
|
help_text="Announcement content in Markdown format with live preview"
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Announcement
|
||||||
|
fields = '__all__'
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Announcement)
|
||||||
|
class AnnouncementAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
form = AnnouncementAdminForm
|
||||||
|
list_display = [
|
||||||
|
'title', 'announcement_type', 'priority', 'author',
|
||||||
|
'is_published', 'publish_date', 'email_sent', 'push_sent', 'created_at'
|
||||||
|
]
|
||||||
|
list_filter = [
|
||||||
|
'announcement_type', 'priority', 'is_published',
|
||||||
|
'send_email', 'send_push', 'target_audience',
|
||||||
|
SoftDeleteListFilter, 'created_at'
|
||||||
|
]
|
||||||
|
search_fields = ['title', 'content', 'author__username']
|
||||||
|
readonly_fields = ['email_sent', 'push_sent', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Content', {
|
||||||
|
'fields': ('title', 'content', 'author')
|
||||||
|
}),
|
||||||
|
('Settings', {
|
||||||
|
'fields': ('announcement_type', 'priority', 'target_audience', 'is_published', 'publish_date')
|
||||||
|
}),
|
||||||
|
('Notifications', {
|
||||||
|
'fields': ('send_email', 'send_push', 'email_sent', 'push_sent')
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + ['publish_announcements', 'send_notifications']
|
||||||
|
|
||||||
|
def publish_announcements(self, request, queryset):
|
||||||
|
queryset.update(is_published=True, publish_date=timezone.now())
|
||||||
|
self.message_user(request, f"{queryset.count()} announcements published.")
|
||||||
|
publish_announcements.short_description = "Publish selected announcements"
|
||||||
|
|
||||||
|
def send_notifications(self, request, queryset):
|
||||||
|
# This will be implemented with Celery tasks
|
||||||
|
for announcement in queryset:
|
||||||
|
if announcement.send_email and not announcement.email_sent:
|
||||||
|
# Trigger email task
|
||||||
|
pass
|
||||||
|
if announcement.send_push and not announcement.push_sent:
|
||||||
|
# Trigger push notification task
|
||||||
|
pass
|
||||||
|
self.message_user(request, f"Notifications queued for {queryset.count()} announcements.")
|
||||||
|
send_notifications.short_description = "Send notifications for selected announcements"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(NewsletterSubscription)
|
||||||
|
class NewsletterSubscriptionAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
list_display = ['email', 'user', 'is_active', 'confirmed_at', 'created_at']
|
||||||
|
list_filter = ['is_active', SoftDeleteListFilter, 'created_at', 'confirmed_at']
|
||||||
|
search_fields = ['email', 'user__username', 'user__email']
|
||||||
|
readonly_fields = ['confirmation_token', 'unsubscribe_token', 'created_at', 'updated_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Subscription', {
|
||||||
|
'fields': ('email', 'user', 'is_active', 'subscribed_categories')
|
||||||
|
}),
|
||||||
|
('Confirmation', {
|
||||||
|
'fields': ('confirmed_at', 'confirmation_token', 'unsubscribe_token')
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + ['activate_subscriptions', 'deactivate_subscriptions']
|
||||||
|
|
||||||
|
def activate_subscriptions(self, request, queryset):
|
||||||
|
queryset.update(is_active=True)
|
||||||
|
self.message_user(request, f"{queryset.count()} subscriptions activated.")
|
||||||
|
activate_subscriptions.short_description = "Activate selected subscriptions"
|
||||||
|
|
||||||
|
def deactivate_subscriptions(self, request, queryset):
|
||||||
|
queryset.update(is_active=False)
|
||||||
|
self.message_user(request, f"{queryset.count()} subscriptions deactivated.")
|
||||||
|
deactivate_subscriptions.short_description = "Deactivate selected subscriptions"
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(PushNotificationDevice)
|
||||||
|
class PushNotificationDeviceAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
list_display = ['user', 'device_type', 'is_active', 'created_at']
|
||||||
|
list_filter = ['device_type', 'is_active', SoftDeleteListFilter, 'created_at']
|
||||||
|
search_fields = ['user__username', 'user__email', 'device_token']
|
||||||
|
readonly_fields = ['created_at', 'updated_at']
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Device', {
|
||||||
|
'fields': ('user', 'device_token', 'device_type', 'is_active')
|
||||||
|
}),
|
||||||
|
('Timestamps', {
|
||||||
|
'fields': ('created_at', 'updated_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
7
backend/communications/apps.py
Normal file
7
backend/communications/apps.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class CommunicationsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'communications'
|
||||||
|
verbose_name = 'Communications'
|
||||||
536
backend/communications/fixtures/communications.json
Normal file
536
backend/communications/fixtures/communications.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
78
backend/communications/migrations/0001_initial.py
Normal file
78
backend/communications/migrations/0001_initial.py
Normal file
@@ -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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
37
backend/communications/migrations/0002_initial.py
Normal file
37
backend/communications/migrations/0002_initial.py
Normal file
@@ -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')},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/communications/migrations/__init__.py
Normal file
0
backend/communications/migrations/__init__.py
Normal file
142
backend/communications/models.py
Normal file
142
backend/communications/models.py
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from utils.models import BaseModel
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementType(models.TextChoices):
|
||||||
|
GENERAL = 'general', 'General'
|
||||||
|
EVENT = 'event', 'Event'
|
||||||
|
ACADEMIC = 'academic', 'Academic'
|
||||||
|
URGENT = 'urgent', 'Urgent'
|
||||||
|
NEWSLETTER = 'newsletter', 'Newsletter'
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementPriority(models.TextChoices):
|
||||||
|
LOW = 'low', 'Low'
|
||||||
|
NORMAL = 'normal', 'Normal'
|
||||||
|
HIGH = 'high', 'High'
|
||||||
|
URGENT = 'urgent', 'Urgent'
|
||||||
|
|
||||||
|
|
||||||
|
class Announcement(BaseModel):
|
||||||
|
title = models.CharField(max_length=200, verbose_name='Title')
|
||||||
|
content = models.TextField(verbose_name='Content')
|
||||||
|
announcement_type = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=AnnouncementType.choices,
|
||||||
|
default=AnnouncementType.GENERAL,
|
||||||
|
verbose_name='Type'
|
||||||
|
)
|
||||||
|
priority = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=AnnouncementPriority.choices,
|
||||||
|
default=AnnouncementPriority.NORMAL,
|
||||||
|
verbose_name='Priority'
|
||||||
|
)
|
||||||
|
author = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='announcements',
|
||||||
|
verbose_name='Author'
|
||||||
|
)
|
||||||
|
is_published = models.BooleanField(default=False, verbose_name='Published')
|
||||||
|
publish_date = models.DateTimeField(null=True, blank=True, verbose_name='Publish Date')
|
||||||
|
send_email = models.BooleanField(default=False, verbose_name='Send Email Notification')
|
||||||
|
send_push = models.BooleanField(default=False, verbose_name='Send Push Notification')
|
||||||
|
email_sent = models.BooleanField(default=False, verbose_name='Email Sent')
|
||||||
|
push_sent = models.BooleanField(default=False, verbose_name='Push Sent')
|
||||||
|
target_audience = models.CharField(
|
||||||
|
max_length=20,
|
||||||
|
choices=[
|
||||||
|
('all', 'All Users'),
|
||||||
|
('members', 'Members Only'),
|
||||||
|
('committee', 'Committee Only'),
|
||||||
|
('subscribers', 'Newsletter Subscribers Only'),
|
||||||
|
],
|
||||||
|
default='all',
|
||||||
|
verbose_name='Target Audience'
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Announcement'
|
||||||
|
verbose_name_plural = 'Announcements'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
@property
|
||||||
|
def content_html(self):
|
||||||
|
"""Convert markdown content to HTML"""
|
||||||
|
import markdown
|
||||||
|
return markdown.markdown(self.content)
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterSubscription(BaseModel):
|
||||||
|
email = models.EmailField(unique=True, verbose_name='Email')
|
||||||
|
user = models.OneToOneField(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
related_name='newsletter_subscription',
|
||||||
|
verbose_name='User'
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name='Active')
|
||||||
|
subscribed_categories = models.JSONField(
|
||||||
|
default=list,
|
||||||
|
blank=True,
|
||||||
|
verbose_name='Subscribed Categories',
|
||||||
|
help_text='List of announcement types to receive'
|
||||||
|
)
|
||||||
|
confirmation_token = models.CharField(max_length=100, blank=True, verbose_name='Confirmation Token')
|
||||||
|
confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name='Confirmed At')
|
||||||
|
unsubscribe_token = models.CharField(max_length=100, blank=True, verbose_name='Unsubscribe Token')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Newsletter Subscription'
|
||||||
|
verbose_name_plural = 'Newsletter Subscriptions'
|
||||||
|
ordering = ['-created_at']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.email
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.confirmation_token:
|
||||||
|
import uuid
|
||||||
|
self.confirmation_token = str(uuid.uuid4())
|
||||||
|
if not self.unsubscribe_token:
|
||||||
|
import uuid
|
||||||
|
self.unsubscribe_token = str(uuid.uuid4())
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
class PushNotificationDevice(BaseModel):
|
||||||
|
user = models.ForeignKey(
|
||||||
|
User,
|
||||||
|
on_delete=models.CASCADE,
|
||||||
|
related_name='push_devices',
|
||||||
|
verbose_name='User'
|
||||||
|
)
|
||||||
|
device_token = models.TextField(verbose_name='Device Token')
|
||||||
|
device_type = models.CharField(
|
||||||
|
max_length=10,
|
||||||
|
choices=[
|
||||||
|
('web', 'Web'),
|
||||||
|
('android', 'Android'),
|
||||||
|
('ios', 'iOS'),
|
||||||
|
],
|
||||||
|
verbose_name='Device Type'
|
||||||
|
)
|
||||||
|
is_active = models.BooleanField(default=True, verbose_name='Active')
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = 'Push Notification Device'
|
||||||
|
verbose_name_plural = 'Push Notification Devices'
|
||||||
|
unique_together = ['user', 'device_token']
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} - {self.device_type}"
|
||||||
194
backend/communications/push_notifications.py
Normal file
194
backend/communications/push_notifications.py
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Any, Optional
|
||||||
|
from pywebpush import webpush, WebPushException
|
||||||
|
|
||||||
|
from communications.models import PushNotificationDevice
|
||||||
|
from events.models import Registration
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PushNotificationService:
|
||||||
|
"""Service for handling web push notifications"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.vapid_private_key = getattr(settings, 'VAPID_PRIVATE_KEY', None)
|
||||||
|
self.vapid_public_key = getattr(settings, 'VAPID_PUBLIC_KEY', None)
|
||||||
|
self.vapid_claims = getattr(settings, 'VAPID_CLAIMS', {})
|
||||||
|
|
||||||
|
def send_notification(
|
||||||
|
self,
|
||||||
|
subscription_info: Dict[str, Any],
|
||||||
|
data: Dict[str, Any],
|
||||||
|
ttl: int = 86400
|
||||||
|
) -> bool:
|
||||||
|
"""
|
||||||
|
Send a push notification to a single device
|
||||||
|
|
||||||
|
Args:
|
||||||
|
subscription_info: Device subscription information
|
||||||
|
data: Notification payload
|
||||||
|
ttl: Time to live in seconds (default 24 hours)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
bool: True if successful, False otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
webpush(
|
||||||
|
subscription_info=subscription_info,
|
||||||
|
data=json.dumps(data),
|
||||||
|
vapid_private_key=self.vapid_private_key,
|
||||||
|
vapid_claims=self.vapid_claims,
|
||||||
|
ttl=ttl
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except WebPushException as e:
|
||||||
|
logger.error(f"Push notification failed: {e}")
|
||||||
|
if e.response and e.response.status_code in [410, 413]:
|
||||||
|
# Subscription is no longer valid, should be removed
|
||||||
|
self._remove_invalid_subscription(subscription_info)
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Unexpected error sending push notification: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def send_to_multiple(
|
||||||
|
self,
|
||||||
|
devices: List[PushNotificationDevice],
|
||||||
|
data: Dict[str, Any],
|
||||||
|
ttl: int = 86400
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Send push notification to multiple devices
|
||||||
|
|
||||||
|
Args:
|
||||||
|
devices: List of PushNotificationDevice objects
|
||||||
|
data: Notification payload
|
||||||
|
ttl: Time to live in seconds
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Statistics of sent/failed notifications
|
||||||
|
"""
|
||||||
|
stats = {'sent': 0, 'failed': 0}
|
||||||
|
|
||||||
|
for device in devices:
|
||||||
|
subscription_info = {
|
||||||
|
'endpoint': device.endpoint,
|
||||||
|
'keys': {
|
||||||
|
'p256dh': device.p256dh_key,
|
||||||
|
'auth': device.auth_key
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.send_notification(subscription_info, data, ttl):
|
||||||
|
stats['sent'] += 1
|
||||||
|
else:
|
||||||
|
stats['failed'] += 1
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def send_announcement_notification(
|
||||||
|
self,
|
||||||
|
announcement,
|
||||||
|
devices: Optional[List[PushNotificationDevice]] = None
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Send push notification for an announcement
|
||||||
|
|
||||||
|
Args:
|
||||||
|
announcement: Announcement model instance
|
||||||
|
devices: Optional list of specific devices to send to
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Statistics of sent/failed notifications
|
||||||
|
"""
|
||||||
|
if devices is None:
|
||||||
|
# Get devices based on announcement audience
|
||||||
|
if announcement.audience == 'all':
|
||||||
|
devices = PushNotificationDevice.objects.filter(is_active=True)
|
||||||
|
elif announcement.audience == 'members':
|
||||||
|
devices = PushNotificationDevice.objects.filter(
|
||||||
|
user__is_member=True,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
elif announcement.audience == 'committee':
|
||||||
|
devices = PushNotificationDevice.objects.filter(
|
||||||
|
user__is_committee_member=True,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
devices = PushNotificationDevice.objects.none()
|
||||||
|
|
||||||
|
# Prepare notification data
|
||||||
|
data = {
|
||||||
|
'title': announcement.title,
|
||||||
|
'body': announcement.content[:100] + '...' if len(announcement.content) > 100 else announcement.content,
|
||||||
|
'icon': '/static/images/logo.png',
|
||||||
|
'badge': '/static/images/badge.png',
|
||||||
|
'data': {
|
||||||
|
'type': 'announcement',
|
||||||
|
'id': announcement.id,
|
||||||
|
'url': f'/announcements/{announcement.id}/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.send_to_multiple(devices, data)
|
||||||
|
|
||||||
|
def send_event_reminder_notification(
|
||||||
|
self,
|
||||||
|
event,
|
||||||
|
devices: Optional[List[PushNotificationDevice]] = None
|
||||||
|
) -> Dict[str, int]:
|
||||||
|
"""
|
||||||
|
Send push notification for event reminder
|
||||||
|
|
||||||
|
Args:
|
||||||
|
event: Event model instance
|
||||||
|
devices: Optional list of specific devices to send to
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Statistics of sent/failed notifications
|
||||||
|
"""
|
||||||
|
if devices is None:
|
||||||
|
# Get devices of registered users
|
||||||
|
registered_users = Registration.objects.filter(
|
||||||
|
event=event,
|
||||||
|
status='confirmed'
|
||||||
|
).values_list('user_id', flat=True)
|
||||||
|
|
||||||
|
devices = PushNotificationDevice.objects.filter(
|
||||||
|
user_id__in=registered_users,
|
||||||
|
is_active=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Prepare notification data
|
||||||
|
data = {
|
||||||
|
'title': f'Event Reminder: {event.title}',
|
||||||
|
'body': f'Your event "{event.title}" starts in 24 hours!',
|
||||||
|
'icon': '/static/images/logo.png',
|
||||||
|
'badge': '/static/images/badge.png',
|
||||||
|
'data': {
|
||||||
|
'type': 'event_reminder',
|
||||||
|
'id': event.id,
|
||||||
|
'url': f'/events/{event.id}/'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return self.send_to_multiple(devices, data)
|
||||||
|
|
||||||
|
def _remove_invalid_subscription(self, subscription_info: Dict[str, Any]):
|
||||||
|
"""Remove invalid subscription from database"""
|
||||||
|
try:
|
||||||
|
PushNotificationDevice.objects.filter(
|
||||||
|
endpoint=subscription_info['endpoint']
|
||||||
|
).delete()
|
||||||
|
logger.info(f"Removed invalid subscription: {subscription_info['endpoint']}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error removing invalid subscription: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# Create a singleton instance
|
||||||
|
push_service = PushNotificationService()
|
||||||
56
backend/communications/resources.py
Normal file
56
backend/communications/resources.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
from import_export import resources, fields
|
||||||
|
from import_export.widgets import ForeignKeyWidget
|
||||||
|
|
||||||
|
from communications.models import Announcement, NewsletterSubscription, PushNotificationDevice
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementResource(resources.ModelResource):
|
||||||
|
author = fields.Field(
|
||||||
|
column_name='author',
|
||||||
|
attribute='author',
|
||||||
|
widget=ForeignKeyWidget(User, 'username')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Announcement
|
||||||
|
fields = (
|
||||||
|
'id', 'title', 'content', 'announcement_type', 'priority',
|
||||||
|
'author', 'is_published', 'publish_date', 'send_email', 'send_push',
|
||||||
|
'target_audience', 'created_at', 'updated_at'
|
||||||
|
)
|
||||||
|
export_order = fields
|
||||||
|
|
||||||
|
|
||||||
|
class NewsletterSubscriptionResource(resources.ModelResource):
|
||||||
|
user = fields.Field(
|
||||||
|
column_name='user',
|
||||||
|
attribute='user',
|
||||||
|
widget=ForeignKeyWidget(User, 'username')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = NewsletterSubscription
|
||||||
|
fields = (
|
||||||
|
'id', 'email', 'user', 'is_active', 'subscribed_categories',
|
||||||
|
'confirmed_at', 'created_at', 'updated_at'
|
||||||
|
)
|
||||||
|
export_order = fields
|
||||||
|
|
||||||
|
|
||||||
|
class PushNotificationDeviceResource(resources.ModelResource):
|
||||||
|
user = fields.Field(
|
||||||
|
column_name='user',
|
||||||
|
attribute='user',
|
||||||
|
widget=ForeignKeyWidget(User, 'username')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = PushNotificationDevice
|
||||||
|
fields = (
|
||||||
|
'id', 'user', 'device_type', 'is_active', 'created_at', 'updated_at'
|
||||||
|
)
|
||||||
|
export_order = fields
|
||||||
278
backend/communications/tasks.py
Normal file
278
backend/communications/tasks.py
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
from django.utils import timezone
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from celery import shared_task
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
from events.models import Event, Registration
|
||||||
|
from communications.models import Announcement, NewsletterSubscription
|
||||||
|
from communications.utils import send_announcement_email, send_event_reminder, get_announcement_recipients
|
||||||
|
from communications.push_notifications import push_service
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
SYSTEM_USER_ID = 1
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, max_retries=3)
|
||||||
|
def send_announcement_notifications(self, announcement_id):
|
||||||
|
"""Send email and push notifications for an announcement"""
|
||||||
|
try:
|
||||||
|
announcement = Announcement.objects.get(id=announcement_id)
|
||||||
|
|
||||||
|
# Send email notifications
|
||||||
|
if announcement.send_email and not announcement.email_sent:
|
||||||
|
recipients = get_announcement_recipients(announcement)
|
||||||
|
if recipients:
|
||||||
|
success = send_announcement_email(announcement, recipients)
|
||||||
|
if success:
|
||||||
|
announcement.email_sent = True
|
||||||
|
announcement.save()
|
||||||
|
logger.info(f"Email notifications sent for announcement {announcement.id}")
|
||||||
|
|
||||||
|
# Send push notifications
|
||||||
|
if announcement.send_push and not announcement.push_sent:
|
||||||
|
sent_count = push_service.send_announcement_notification(announcement)
|
||||||
|
if sent_count > 0:
|
||||||
|
announcement.push_sent = True
|
||||||
|
announcement.save()
|
||||||
|
logger.info(f"Push notifications sent to {sent_count} devices for announcement {announcement.id}")
|
||||||
|
|
||||||
|
return f"Notifications sent for announcement: {announcement.title}"
|
||||||
|
|
||||||
|
except Announcement.DoesNotExist:
|
||||||
|
logger.error(f"Announcement {announcement_id} not found")
|
||||||
|
return f"Announcement {announcement_id} not found"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send announcement notifications: {exc}")
|
||||||
|
raise self.retry(exc=exc, countdown=60)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, max_retries=3)
|
||||||
|
def send_newsletter_confirmation_task(self, subscription_id):
|
||||||
|
"""Send newsletter confirmation email"""
|
||||||
|
try:
|
||||||
|
from .utils import send_newsletter_confirmation
|
||||||
|
|
||||||
|
subscription = NewsletterSubscription.objects.get(id=subscription_id)
|
||||||
|
success = send_newsletter_confirmation(subscription)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
logger.info(f"Newsletter confirmation sent to {subscription.email}")
|
||||||
|
return f"Newsletter confirmation sent to {subscription.email}"
|
||||||
|
else:
|
||||||
|
raise Exception("Failed to send newsletter confirmation")
|
||||||
|
|
||||||
|
except NewsletterSubscription.DoesNotExist:
|
||||||
|
logger.error(f"Newsletter subscription {subscription_id} not found")
|
||||||
|
return f"Newsletter subscription {subscription_id} not found"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send newsletter confirmation: {exc}")
|
||||||
|
raise self.retry(exc=exc, countdown=60)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_event_reminders():
|
||||||
|
"""Send reminders for events starting about 24 hours from now within a 30-minute window."""
|
||||||
|
try:
|
||||||
|
reminder_target = timezone.now() + timedelta(hours=24)
|
||||||
|
window = timedelta(minutes=30)
|
||||||
|
start_range = reminder_target - window
|
||||||
|
end_range = reminder_target + window
|
||||||
|
|
||||||
|
events = Event.objects.filter(
|
||||||
|
start_time__range=(start_range, end_range),
|
||||||
|
status='published',
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
|
||||||
|
total_sent = 0
|
||||||
|
|
||||||
|
for event in events:
|
||||||
|
# Get confirmed registrations
|
||||||
|
registrations = Registration.objects.filter(
|
||||||
|
event=event,
|
||||||
|
status='confirmed',
|
||||||
|
is_deleted=False
|
||||||
|
).select_related('user')
|
||||||
|
|
||||||
|
for registration in registrations:
|
||||||
|
try:
|
||||||
|
# Send email reminder
|
||||||
|
send_event_reminder(event, registration.user)
|
||||||
|
|
||||||
|
# Send push notification reminder
|
||||||
|
push_service.send_event_reminder_notification(event, registration.user)
|
||||||
|
|
||||||
|
total_sent += 1
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send reminder to {registration.user.email}: {str(e)}")
|
||||||
|
|
||||||
|
logger.info(f"Event reminders sent to {total_sent} users")
|
||||||
|
return f"Event reminders sent to {total_sent} users"
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send event reminders: {exc}")
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_weekly_newsletter():
|
||||||
|
"""Send the weekly newsletter as the system user with recent announcements and upcoming events."""
|
||||||
|
try:
|
||||||
|
# Get active newsletter subscribers
|
||||||
|
subscribers = NewsletterSubscription.objects.filter(
|
||||||
|
is_active=True,
|
||||||
|
confirmed_at__isnull=False,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if not subscribers.exists():
|
||||||
|
logger.info("No active newsletter subscribers found")
|
||||||
|
return "No active newsletter subscribers found"
|
||||||
|
|
||||||
|
# Get recent announcements (last 7 days)
|
||||||
|
week_ago = timezone.now() - timedelta(days=7)
|
||||||
|
recent_announcements = Announcement.objects.filter(
|
||||||
|
is_published=True,
|
||||||
|
publish_date__gte=week_ago,
|
||||||
|
announcement_type__in=['general', 'academic', 'newsletter'],
|
||||||
|
is_deleted=False
|
||||||
|
).order_by('-publish_date')[:5]
|
||||||
|
|
||||||
|
# Get upcoming events (next 14 days)
|
||||||
|
two_weeks_ahead = timezone.now() + timedelta(days=14)
|
||||||
|
upcoming_events = Event.objects.filter(
|
||||||
|
start_time__range=(timezone.now(), two_weeks_ahead),
|
||||||
|
status='published',
|
||||||
|
is_deleted=False
|
||||||
|
).order_by('start_time')[:5]
|
||||||
|
|
||||||
|
newsletter_content = f"""
|
||||||
|
# Weekly Newsletter - {timezone.now().strftime('%B %d, %Y')}
|
||||||
|
|
||||||
|
## Recent Announcements
|
||||||
|
"""
|
||||||
|
|
||||||
|
for announcement in recent_announcements:
|
||||||
|
newsletter_content += f"- **{announcement.title}** ({announcement.publish_date.strftime('%B %d')})\n"
|
||||||
|
|
||||||
|
newsletter_content += "\n## Upcoming Events\n"
|
||||||
|
|
||||||
|
for event in upcoming_events:
|
||||||
|
newsletter_content += f"- **{event.title}** - {event.start_time.strftime('%B %d, %Y at %I:%M %p')}\n"
|
||||||
|
|
||||||
|
if not recent_announcements.exists() and not upcoming_events.exists():
|
||||||
|
newsletter_content += "\nNo recent announcements or upcoming events this week."
|
||||||
|
|
||||||
|
newsletter = Announcement.objects.create(
|
||||||
|
title=f"Weekly Newsletter - {timezone.now().strftime('%B %d, %Y')}",
|
||||||
|
content=newsletter_content,
|
||||||
|
announcement_type='newsletter',
|
||||||
|
priority='normal',
|
||||||
|
author_id=SYSTEM_USER_ID,
|
||||||
|
is_published=True,
|
||||||
|
publish_date=timezone.now(),
|
||||||
|
send_email=True,
|
||||||
|
target_audience='subscribers'
|
||||||
|
)
|
||||||
|
|
||||||
|
# Send to subscribers
|
||||||
|
subscriber_emails = list(subscribers.values_list('email', flat=True))
|
||||||
|
success = send_announcement_email(newsletter, subscriber_emails)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
newsletter.email_sent = True
|
||||||
|
newsletter.save()
|
||||||
|
logger.info(f"Weekly newsletter sent to {len(subscriber_emails)} subscribers")
|
||||||
|
return f"Weekly newsletter sent to {len(subscriber_emails)} subscribers"
|
||||||
|
else:
|
||||||
|
raise Exception("Failed to send weekly newsletter")
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send weekly newsletter: {exc}")
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def cleanup_expired_tokens():
|
||||||
|
"""Clean up expired newsletter confirmation tokens"""
|
||||||
|
try:
|
||||||
|
# Remove unconfirmed subscriptions older than 7 days
|
||||||
|
week_ago = timezone.now() - timedelta(days=7)
|
||||||
|
expired_subscriptions = NewsletterSubscription.objects.filter(
|
||||||
|
confirmed_at__isnull=True,
|
||||||
|
created_at__lt=week_ago
|
||||||
|
)
|
||||||
|
|
||||||
|
count = expired_subscriptions.count()
|
||||||
|
expired_subscriptions.delete()
|
||||||
|
|
||||||
|
logger.info(f"Cleaned up {count} expired newsletter subscriptions")
|
||||||
|
return f"Cleaned up {count} expired newsletter subscriptions"
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to cleanup expired tokens: {exc}")
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_bulk_announcement(announcement_id, recipient_emails):
|
||||||
|
"""Send announcement to a specific list of recipients"""
|
||||||
|
try:
|
||||||
|
announcement = Announcement.objects.get(id=announcement_id)
|
||||||
|
|
||||||
|
# Split recipients into batches to avoid overwhelming the email server
|
||||||
|
batch_size = 50
|
||||||
|
total_sent = 0
|
||||||
|
|
||||||
|
for i in range(0, len(recipient_emails), batch_size):
|
||||||
|
batch = recipient_emails[i:i + batch_size]
|
||||||
|
success = send_announcement_email(announcement, batch)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
total_sent += len(batch)
|
||||||
|
logger.info(f"Sent announcement to batch of {len(batch)} recipients")
|
||||||
|
|
||||||
|
# Small delay between batches
|
||||||
|
import time
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
logger.info(f"Bulk announcement sent to {total_sent} recipients")
|
||||||
|
return f"Bulk announcement sent to {total_sent} recipients"
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send bulk announcement: {exc}")
|
||||||
|
raise exc
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def process_scheduled_announcements():
|
||||||
|
"""Process announcements scheduled for publication"""
|
||||||
|
try:
|
||||||
|
now = timezone.now()
|
||||||
|
|
||||||
|
# Get announcements scheduled for publication
|
||||||
|
scheduled_announcements = Announcement.objects.filter(
|
||||||
|
is_published=True,
|
||||||
|
publish_date__lte=now,
|
||||||
|
email_sent=False,
|
||||||
|
send_email=True,
|
||||||
|
is_deleted=False
|
||||||
|
)
|
||||||
|
|
||||||
|
processed_count = 0
|
||||||
|
|
||||||
|
for announcement in scheduled_announcements:
|
||||||
|
# Send notifications
|
||||||
|
send_announcement_notifications.delay(announcement.id)
|
||||||
|
processed_count += 1
|
||||||
|
|
||||||
|
logger.info(f"Processed {processed_count} scheduled announcements")
|
||||||
|
return f"Processed {processed_count} scheduled announcements"
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to process scheduled announcements: {exc}")
|
||||||
|
raise exc
|
||||||
140
backend/communications/utils.py
Normal file
140
backend/communications/utils.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.core.mail import send_mail
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from communications.models import NewsletterSubscription
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def send_announcement_email(announcement, recipients):
|
||||||
|
"""Send announcement email to recipients"""
|
||||||
|
try:
|
||||||
|
template_name = f'emails/announcement_email.html'
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'announcement': announcement,
|
||||||
|
'unsubscribe_url': f"{settings.FRONTEND_ROOT}newsletter/unsubscribe/",
|
||||||
|
'manage_subscription_url': f"{settings.FRONTEND_ROOT}newsletter/manage-subscription",
|
||||||
|
}
|
||||||
|
|
||||||
|
html_message = render_to_string(template_name, context)
|
||||||
|
plain_message = strip_tags(html_message)
|
||||||
|
|
||||||
|
subject = f"انجمن علمی کامپیوتر گیلان | {announcement.title}"
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=plain_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=recipients,
|
||||||
|
html_message=html_message,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Announcement email sent to {len(recipients)} recipients")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send announcement email: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_newsletter_confirmation(subscription):
|
||||||
|
"""Send newsletter confirmation email"""
|
||||||
|
try:
|
||||||
|
template_name = f'emails/newsletter_confirmation.html'
|
||||||
|
|
||||||
|
confirmation_url = f"{settings.FRONTEND_ROOT}confirm-subscription/{subscription.confirmation_token}"
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'subscription': subscription,
|
||||||
|
'confirmation_url': confirmation_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
html_message = render_to_string(template_name, context)
|
||||||
|
plain_message = strip_tags(html_message)
|
||||||
|
|
||||||
|
subject = "تأیید اشتراک خبرنامه"
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=plain_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[subscription.email],
|
||||||
|
html_message=html_message,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Newsletter confirmation sent to {subscription.email}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send newsletter confirmation: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def send_event_reminder(event, user):
|
||||||
|
"""Send event reminder email"""
|
||||||
|
try:
|
||||||
|
template_name = f'emails/event_reminder.html'
|
||||||
|
|
||||||
|
event_url = f"{settings.FRONTEND_ROOT}events/{event.slug}"
|
||||||
|
|
||||||
|
context = {
|
||||||
|
'event': event,
|
||||||
|
'user': user,
|
||||||
|
'event_url': event_url,
|
||||||
|
}
|
||||||
|
|
||||||
|
html_message = render_to_string(template_name, context)
|
||||||
|
plain_message = strip_tags(html_message)
|
||||||
|
|
||||||
|
subject = f"یادآوری رویداد: {event.title}"
|
||||||
|
|
||||||
|
send_mail(
|
||||||
|
subject=subject,
|
||||||
|
message=plain_message,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
recipient_list=[user.email],
|
||||||
|
html_message=html_message,
|
||||||
|
fail_silently=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Event reminder sent to {user.email} for event {event.title}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to send event reminder: {str(e)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_announcement_recipients(announcement):
|
||||||
|
"""Get list of email addresses based on announcement target audience"""
|
||||||
|
|
||||||
|
User = get_user_model()
|
||||||
|
recipients = []
|
||||||
|
|
||||||
|
if announcement.target_audience == 'all':
|
||||||
|
# All users with email
|
||||||
|
recipients = list(User.objects.filter(email__isnull=False).values_list('email', flat=True))
|
||||||
|
|
||||||
|
elif announcement.target_audience == 'members':
|
||||||
|
# Only members (users with is_member=True)
|
||||||
|
recipients = list(User.objects.filter(is_member=True, email__isnull=False).values_list('email', flat=True))
|
||||||
|
|
||||||
|
elif announcement.target_audience == 'committee':
|
||||||
|
# Only committee members
|
||||||
|
recipients = list(User.objects.filter(is_committee=True, email__isnull=False).values_list('email', flat=True))
|
||||||
|
|
||||||
|
elif announcement.target_audience == 'subscribers':
|
||||||
|
# Only newsletter subscribers
|
||||||
|
recipients = list(NewsletterSubscription.objects.filter(
|
||||||
|
is_active=True,
|
||||||
|
confirmed_at__isnull=False
|
||||||
|
).values_list('email', flat=True))
|
||||||
|
|
||||||
|
return recipients
|
||||||
3
backend/config/__init__.py
Normal file
3
backend/config/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from config.services.celery import app as celery_app
|
||||||
|
|
||||||
|
__all__ = ('celery_app',)
|
||||||
7
backend/config/asgi.py
Normal file
7
backend/config/asgi.py
Normal file
@@ -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()
|
||||||
56
backend/config/services/celery.py
Normal file
56
backend/config/services/celery.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Celery application configuration and scheduling."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
from celery import Celery
|
||||||
|
from celery.schedules import crontab
|
||||||
|
from decouple import config
|
||||||
|
|
||||||
|
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings.development')
|
||||||
|
|
||||||
|
app = Celery('config')
|
||||||
|
app.config_from_object('django.conf:settings', namespace='CELERY')
|
||||||
|
app.autodiscover_tasks()
|
||||||
|
|
||||||
|
app.conf.update(
|
||||||
|
broker_url=config('REDIS_URL', default='redis://localhost:6379/0'),
|
||||||
|
result_backend=config('REDIS_URL', default='redis://localhost:6379/0'),
|
||||||
|
task_serializer='json',
|
||||||
|
accept_content=['json'],
|
||||||
|
result_serializer='json',
|
||||||
|
timezone='UTC',
|
||||||
|
enable_utc=True,
|
||||||
|
task_track_started=True,
|
||||||
|
task_time_limit=30 * 60,
|
||||||
|
task_soft_time_limit=60,
|
||||||
|
worker_prefetch_multiplier=1,
|
||||||
|
worker_max_tasks_per_child=1000,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.conf.beat_schedule = {
|
||||||
|
'send-event-reminders': {
|
||||||
|
'task': 'communications.tasks.send_event_reminders',
|
||||||
|
'schedule': crontab(minute=0, hour='*/1'),
|
||||||
|
'description': 'Runs hourly to notify about upcoming events.',
|
||||||
|
},
|
||||||
|
'send-weekly-newsletter': {
|
||||||
|
'task': 'communications.tasks.send_weekly_newsletter',
|
||||||
|
'schedule': crontab(hour=9, minute=0, day_of_week=1),
|
||||||
|
'description': 'Runs every Monday at 09:00 UTC.',
|
||||||
|
},
|
||||||
|
'cleanup-expired-tokens': {
|
||||||
|
'task': 'communications.tasks.cleanup_expired_tokens',
|
||||||
|
'schedule': crontab(hour=2, minute=0),
|
||||||
|
'description': 'Runs daily at 02:00 UTC.',
|
||||||
|
},
|
||||||
|
'process-scheduled-announcements': {
|
||||||
|
'task': 'communications.tasks.process_scheduled_announcements',
|
||||||
|
'schedule': crontab(minute='*/15'),
|
||||||
|
'description': 'Runs every 15 minutes to dispatch scheduled announcements.',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
EMAIL_TIMEOUT_SECONDS = 10
|
||||||
|
|
||||||
|
CELERY_TASK_SOFT_TIME_LIMIT = 20
|
||||||
|
CELERY_TASK_TIME_LIMIT = 30
|
||||||
14
backend/config/services/location.py
Normal file
14
backend/config/services/location.py
Normal file
@@ -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'},
|
||||||
|
}
|
||||||
12
backend/config/services/notifications.py
Normal file
12
backend/config/services/notifications.py
Normal file
@@ -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')
|
||||||
94
backend/config/services/unfold.py
Normal file
94
backend/config/services/unfold.py
Normal file
@@ -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"]
|
||||||
10
backend/config/services/zarinpal.py
Normal file
10
backend/config/services/zarinpal.py
Normal file
@@ -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')
|
||||||
233
backend/config/settings/base.py
Normal file
233
backend/config/settings/base.py
Normal file
@@ -0,0 +1,233 @@
|
|||||||
|
from decouple import config
|
||||||
|
from pathlib import Path
|
||||||
|
import os
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent.parent
|
||||||
|
|
||||||
|
SECRET_KEY = config('SECRET_KEY')
|
||||||
|
|
||||||
|
DEBUG = config('DEBUG', default=False, cast=bool)
|
||||||
|
|
||||||
|
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default='').split(',')
|
||||||
|
|
||||||
|
DJANGO_APPS = [
|
||||||
|
'unfold',
|
||||||
|
'unfold.contrib.filters',
|
||||||
|
'unfold.contrib.forms',
|
||||||
|
'unfold.contrib.import_export',
|
||||||
|
'unfold.contrib.location_field',
|
||||||
|
|
||||||
|
'django.contrib.admin',
|
||||||
|
'django.contrib.auth',
|
||||||
|
'django.contrib.contenttypes',
|
||||||
|
'django.contrib.sessions',
|
||||||
|
'django.contrib.messages',
|
||||||
|
'django.contrib.staticfiles',
|
||||||
|
]
|
||||||
|
|
||||||
|
THIRD_PARTY_APPS = [
|
||||||
|
'corsheaders',
|
||||||
|
'import_export',
|
||||||
|
'simplemde',
|
||||||
|
'location_field',
|
||||||
|
"django_prometheus",
|
||||||
|
]
|
||||||
|
|
||||||
|
LOCAL_APPS = [
|
||||||
|
'users',
|
||||||
|
'blog',
|
||||||
|
'gallery',
|
||||||
|
'events',
|
||||||
|
'certificates',
|
||||||
|
'communications',
|
||||||
|
'payments',
|
||||||
|
'utils',
|
||||||
|
]
|
||||||
|
|
||||||
|
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||||
|
|
||||||
|
MIDDLEWARE = [
|
||||||
|
"django_prometheus.middleware.PrometheusBeforeMiddleware",
|
||||||
|
'corsheaders.middleware.CorsMiddleware',
|
||||||
|
'django.middleware.security.SecurityMiddleware',
|
||||||
|
'whitenoise.middleware.WhiteNoiseMiddleware',
|
||||||
|
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||||
|
'django.middleware.common.CommonMiddleware',
|
||||||
|
'django.middleware.csrf.CsrfViewMiddleware',
|
||||||
|
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||||
|
'django.contrib.messages.middleware.MessageMiddleware',
|
||||||
|
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||||
|
"django_prometheus.middleware.PrometheusAfterMiddleware",
|
||||||
|
]
|
||||||
|
|
||||||
|
ROOT_URLCONF = 'config.urls'
|
||||||
|
|
||||||
|
TEMPLATES = [
|
||||||
|
{
|
||||||
|
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||||
|
'DIRS': [BASE_DIR / 'templates'],
|
||||||
|
'APP_DIRS': True,
|
||||||
|
'OPTIONS': {
|
||||||
|
'context_processors': [
|
||||||
|
'django.template.context_processors.debug',
|
||||||
|
'django.template.context_processors.request',
|
||||||
|
'django.contrib.auth.context_processors.auth',
|
||||||
|
'django.contrib.messages.context_processors.messages',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
WSGI_APPLICATION = 'config.wsgi.application'
|
||||||
|
|
||||||
|
# Database
|
||||||
|
DATABASES = {
|
||||||
|
'default': {
|
||||||
|
'ENGINE': config('DB_ENGINE', 'django.db.backends.sqlite3'),
|
||||||
|
'NAME': config('DB_NAME', BASE_DIR / 'db.sqlite3'),
|
||||||
|
'USER': config('DB_USER'),
|
||||||
|
'PASSWORD': config('DB_PASSWORD'),
|
||||||
|
'HOST': config('DB_HOST', default='localhost'),
|
||||||
|
'PORT': config('DB_PORT', default='5432'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Password validation
|
||||||
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
LANGUAGE_CODE = 'en-us'
|
||||||
|
TIME_ZONE = 'Asia/Tehran'
|
||||||
|
|
||||||
|
LANGUAGES = [
|
||||||
|
('en', 'English'),
|
||||||
|
('fa', 'فارسی'),
|
||||||
|
]
|
||||||
|
|
||||||
|
USE_I18N = True
|
||||||
|
USE_L10N = True
|
||||||
|
USE_TZ = True
|
||||||
|
|
||||||
|
# For RTL support in admin
|
||||||
|
LOCALE_PATHS = [BASE_DIR / 'locale']
|
||||||
|
|
||||||
|
STATIC_URL = config('STATIC_URL', default='/static/')
|
||||||
|
STATIC_ROOT = BASE_DIR / 'staticfiles'
|
||||||
|
STATICFILES_DIRS = [BASE_DIR / 'static']
|
||||||
|
|
||||||
|
MEDIA_URL = config('MEDIA_URL', default='/media/')
|
||||||
|
MEDIA_ROOT = BASE_DIR / 'media'
|
||||||
|
|
||||||
|
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||||
|
|
||||||
|
AUTH_USER_MODEL = 'users.User'
|
||||||
|
|
||||||
|
# CORS Settings
|
||||||
|
CORS_ALLOWED_ORIGINS = config('CORS_ALLOWED_ORIGINS', default='https://east-guilan-ce.ir').split(',')
|
||||||
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
CSRF_TRUSTED_ORIGINS = ["https://east-guilan-ce.ir"]
|
||||||
|
CSRF_COOKIE_SECURE = True
|
||||||
|
SESSION_COOKIE_SECURE = True
|
||||||
|
|
||||||
|
# Email Configuration
|
||||||
|
EMAIL_BACKEND = config('EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend')
|
||||||
|
EMAIL_HOST = config('EMAIL_HOST', default='')
|
||||||
|
EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int)
|
||||||
|
EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool)
|
||||||
|
EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='')
|
||||||
|
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')
|
||||||
|
DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='webmaster@localhost')
|
||||||
|
|
||||||
|
# JWT Configuration
|
||||||
|
JWT_SECRET_KEY = config('JWT_SECRET_KEY', default=SECRET_KEY)
|
||||||
|
JWT_ALGORITHM = config('JWT_ALGORITHM', default='HS256')
|
||||||
|
JWT_ACCESS_TOKEN_LIFETIME = config('JWT_ACCESS_TOKEN_LIFETIME', default=3600, cast=int)
|
||||||
|
JWT_REFRESH_TOKEN_LIFETIME = config('JWT_REFRESH_TOKEN_LIFETIME', default=86400, cast=int)
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_URL = config('REDIS_URL', default='redis://localhost:6379/0')
|
||||||
|
|
||||||
|
# Cache Configuration
|
||||||
|
CACHES = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'django_prometheus.cache.backends.redis.RedisCache',
|
||||||
|
'LOCATION': REDIS_URL,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# Celery Configuration
|
||||||
|
CELERY_BROKER_URL = REDIS_URL
|
||||||
|
CELERY_RESULT_BACKEND = REDIS_URL
|
||||||
|
|
||||||
|
|
||||||
|
# Logging Configuration
|
||||||
|
LOGGING = {
|
||||||
|
'version': 1,
|
||||||
|
'disable_existing_loggers': False,
|
||||||
|
'formatters': {
|
||||||
|
'verbose': {
|
||||||
|
'format': '{levelname} {asctime} {module} {process:d} {thread:d} {message}',
|
||||||
|
'style': '{',
|
||||||
|
},
|
||||||
|
'simple': {
|
||||||
|
'format': '{levelname} {message}',
|
||||||
|
'style': '{',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'handlers': {
|
||||||
|
'file': {
|
||||||
|
'level': 'INFO',
|
||||||
|
'class': 'logging.FileHandler',
|
||||||
|
'filename': BASE_DIR / 'logs' / 'django.log',
|
||||||
|
'formatter': 'verbose',
|
||||||
|
},
|
||||||
|
'console': {
|
||||||
|
'level': 'INFO',
|
||||||
|
'class': 'logging.StreamHandler',
|
||||||
|
'formatter': 'simple',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'root': {
|
||||||
|
'handlers': ['console'],
|
||||||
|
'level': 'INFO',
|
||||||
|
},
|
||||||
|
'loggers': {
|
||||||
|
'django': {
|
||||||
|
'handlers': ['file', 'console'],
|
||||||
|
'level': 'INFO',
|
||||||
|
'propagate': False,
|
||||||
|
},
|
||||||
|
'apps': {
|
||||||
|
'handlers': ['file', 'console'],
|
||||||
|
'level': 'INFO',
|
||||||
|
'propagate': False,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Create logs directory
|
||||||
|
os.makedirs(BASE_DIR / 'logs', exist_ok=True)
|
||||||
|
|
||||||
|
BACKEND_ROOT = config('DJANGO_HOST', default='http://localhost:8000/')
|
||||||
|
FRONTEND_ROOT = config('FRONTEND_ROOT', default='http://localhost:3000/')
|
||||||
|
FRONTEND_PASSWORD_RESET_PAGE = config('FRONTEND_PASSWORD_RESET_PAGE', default='http://localhost:3000/api/auth/reset-password-confirm/')
|
||||||
|
FRONTEND_CALLBACK_URL = config('FRONTEND_CALLBACK_URL', default='http://localhost:3000/payments/result')
|
||||||
|
|
||||||
|
DATABASES["default"]["ENGINE"] = "django_prometheus.db.backends.postgresql"
|
||||||
|
|
||||||
|
from config.services.unfold import *
|
||||||
|
from config.services.location import *
|
||||||
|
from config.services.notifications import *
|
||||||
|
from config.services.zarinpal import *
|
||||||
18
backend/config/settings/development.py
Normal file
18
backend/config/settings/development.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
from .base import *
|
||||||
|
|
||||||
|
DEBUG = True
|
||||||
|
|
||||||
|
# Additional development settings
|
||||||
|
INTERNAL_IPS = [
|
||||||
|
"127.0.0.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Email backend for development
|
||||||
|
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
|
||||||
|
|
||||||
|
# Disable caching in development
|
||||||
|
CACHES = {
|
||||||
|
'default': {
|
||||||
|
'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
|
||||||
|
}
|
||||||
|
}
|
||||||
21
backend/config/settings/production.py
Normal file
21
backend/config/settings/production.py
Normal file
@@ -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'
|
||||||
46
backend/config/settings/test.py
Normal file
46
backend/config/settings/test.py
Normal file
@@ -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]
|
||||||
24
backend/config/urls.py
Normal file
24
backend/config/urls.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.urls import path, include
|
||||||
|
from django.conf import settings
|
||||||
|
from django.conf.urls.static import static
|
||||||
|
from ninja import NinjaAPI
|
||||||
|
from api.urls import router as api_router
|
||||||
|
|
||||||
|
api = NinjaAPI(
|
||||||
|
title="CS Association API",
|
||||||
|
version="1.0.0",
|
||||||
|
description="API for University Computer Science Association",
|
||||||
|
)
|
||||||
|
|
||||||
|
api.add_router("", api_router)
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('admin/', admin.site.urls),
|
||||||
|
path('api/', api.urls),
|
||||||
|
path("", include("django_prometheus.urls")),
|
||||||
|
]
|
||||||
|
|
||||||
|
if settings.DEBUG:
|
||||||
|
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||||
|
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)
|
||||||
7
backend/config/wsgi.py
Normal file
7
backend/config/wsgi.py
Normal file
@@ -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()
|
||||||
26
backend/docker/entrypoint.sh
Normal file
26
backend/docker/entrypoint.sh
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${DJANGO_WSGI_MODULE:=config.wsgi:application}"
|
||||||
|
: "${DATABASE_URL:=postgres://${DB_USER:-postgres}:${DB_PASSWORD:-postgres}@db:5432/${DB_NAME:-app}}"
|
||||||
|
|
||||||
|
# wait for db
|
||||||
|
host="db"
|
||||||
|
port="5432"
|
||||||
|
for i in {1..60}; do
|
||||||
|
if nc -z "$host" "$port"; then
|
||||||
|
echo "DB ready"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
echo "Waiting for DB... ($i)"
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
|
||||||
|
python manage.py migrate --noinput || true
|
||||||
|
python manage.py collectstatic --noinput || true
|
||||||
|
|
||||||
|
# Start gunicorn (API)
|
||||||
|
( exec gunicorn "$DJANGO_WSGI_MODULE" --bind 0.0.0.0:8000 --workers ${GUNICORN_WORKERS:-3} --threads ${GUNICORN_THREADS:-2} --timeout 60 ) &
|
||||||
|
|
||||||
|
# Start nginx (Frontend)
|
||||||
|
exec nginx -g "daemon off;"
|
||||||
23
backend/docker/nginx.conf
Normal file
23
backend/docker/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
server {
|
||||||
|
listen 8080;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /static/ {
|
||||||
|
alias /app/staticfiles/;
|
||||||
|
access_log off;
|
||||||
|
expires 30d;
|
||||||
|
}
|
||||||
|
|
||||||
|
location /media/ {
|
||||||
|
alias /app/media/;
|
||||||
|
access_log off;
|
||||||
|
expires 30d;
|
||||||
|
}
|
||||||
|
}
|
||||||
418
backend/events/admin.py
Normal file
418
backend/events/admin.py
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
from django.contrib import admin, messages
|
||||||
|
from django.template.response import TemplateResponse
|
||||||
|
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.conf import settings
|
||||||
|
from django.shortcuts import redirect
|
||||||
|
from django.urls import reverse_lazy
|
||||||
|
|
||||||
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
from utils.templatetags.jalali import jdate
|
||||||
|
from unfold.decorators import action as unfold_action
|
||||||
|
|
||||||
|
from utils.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||||
|
from events.models import Event, Registration, EventEmailLog
|
||||||
|
from events.resources import EventResource, RegistrationResource
|
||||||
|
from events.tasks import (
|
||||||
|
queue_skyroom_credentials,
|
||||||
|
send_skyroom_credentials_individual_task,
|
||||||
|
send_event_reminder_task,
|
||||||
|
queue_event_announcement,
|
||||||
|
queue_invites_to_non_registered_users,
|
||||||
|
)
|
||||||
|
from events.admin_forms import AnnouncementForm
|
||||||
|
from events.tasks import _send_html_email
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Event)
|
||||||
|
class EventAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
resource_class = EventResource
|
||||||
|
list_display = (
|
||||||
|
'title', 'event_type', 'start_time_display', 'end_time_display', 'status',
|
||||||
|
'price_display', 'capacity_display', 'attendees_display', 'is_registration_open_display'
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
'event_type', 'status', 'is_deleted',
|
||||||
|
'start_time', 'end_time', 'registration_start_date', 'registration_end_date',
|
||||||
|
SoftDeleteListFilter
|
||||||
|
)
|
||||||
|
search_fields = ('title', 'description', 'address')
|
||||||
|
prepopulated_fields = {'slug': ('title',)}
|
||||||
|
date_hierarchy = 'start_time'
|
||||||
|
filter_horizontal = ('gallery_images',)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Event Details', {
|
||||||
|
'fields': ('title', 'slug', 'description', 'featured_image')
|
||||||
|
}),
|
||||||
|
('Timing & Type', {
|
||||||
|
'fields': ('start_time', 'end_time', 'event_type', 'status')
|
||||||
|
}),
|
||||||
|
('Location & Online', {
|
||||||
|
'fields': ('address', 'location', 'online_link'),
|
||||||
|
'description': 'For On-Site or Hybrid events, provide address and select on map. For Online events, provide a link.'
|
||||||
|
}),
|
||||||
|
('Registration & Pricing', {
|
||||||
|
'fields': ('capacity', 'price', 'registration_start_date', 'registration_end_date', 'registration_success_markdown'),
|
||||||
|
'description': 'Leave capacity blank for unlimited. Leave price blank for free events.'
|
||||||
|
}),
|
||||||
|
('Gallery', {
|
||||||
|
'fields': ('gallery_images',),
|
||||||
|
'description': 'Add images related to this event from the Gallery app.'
|
||||||
|
}),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly_fields = ('deleted_at',)
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + [
|
||||||
|
'make_published',
|
||||||
|
'make_draft',
|
||||||
|
'make_cancelled',
|
||||||
|
'make_completed',
|
||||||
|
'restore_events',
|
||||||
|
]
|
||||||
|
|
||||||
|
actions_row = [
|
||||||
|
'action_send_announcement',
|
||||||
|
'action_send_reminder_now',
|
||||||
|
'action_send_skyroom_credentials',
|
||||||
|
'action_invite_other_users',
|
||||||
|
]
|
||||||
|
|
||||||
|
@admin.display(description="Price")
|
||||||
|
def price_display(self, obj):
|
||||||
|
return obj.price if obj.price is not None else "رایگان"
|
||||||
|
|
||||||
|
@admin.display(description="Start")
|
||||||
|
def start_time_display(self, obj):
|
||||||
|
return jdate(obj.start_time)
|
||||||
|
|
||||||
|
@admin.display(description="End")
|
||||||
|
def end_time_display(self, obj):
|
||||||
|
return jdate(obj.end_time)
|
||||||
|
|
||||||
|
@admin.display(description="Capacity")
|
||||||
|
def capacity_display(self, obj):
|
||||||
|
return obj.capacity if obj.capacity is not None else "نامحدود"
|
||||||
|
|
||||||
|
@admin.display(description="Attendees")
|
||||||
|
def attendees_display(self, obj):
|
||||||
|
return obj.current_attendees_count
|
||||||
|
|
||||||
|
@admin.display(description="Open", boolean=True)
|
||||||
|
def is_registration_open_display(self, obj):
|
||||||
|
return obj.is_registration_open
|
||||||
|
|
||||||
|
@admin.action(description="Mark selected events as published")
|
||||||
|
def make_published(self, request, queryset):
|
||||||
|
queryset.update(status=Event.StatusChoices.PUBLISHED)
|
||||||
|
self.message_user(request, f"Published {queryset.count()} events.")
|
||||||
|
|
||||||
|
@admin.action(description="Mark selected events as draft")
|
||||||
|
def make_draft(self, request, queryset):
|
||||||
|
queryset.update(status=Event.StatusChoices.DRAFT)
|
||||||
|
self.message_user(request, f"Marked {queryset.count()} events as draft.")
|
||||||
|
|
||||||
|
@admin.action(description="Mark selected events as cancelled")
|
||||||
|
def make_cancelled(self, request, queryset):
|
||||||
|
queryset.update(status=Event.StatusChoices.CANCELLED)
|
||||||
|
self.message_user(request, f"Cancelled {queryset.count()} events.")
|
||||||
|
|
||||||
|
@admin.action(description="Mark selected events as completed")
|
||||||
|
def make_completed(self, request, queryset):
|
||||||
|
queryset.update(status=Event.StatusChoices.COMPLETED)
|
||||||
|
self.message_user(request, f"Marked {queryset.count()} events as completed.")
|
||||||
|
|
||||||
|
@admin.action(description="Restore selected events")
|
||||||
|
def restore_events(self, request, queryset):
|
||||||
|
for event in queryset:
|
||||||
|
event.restore()
|
||||||
|
self.message_user(request, f"Restored {queryset.count()} events.")
|
||||||
|
|
||||||
|
@unfold_action(description="Send Skyroom Credentials")
|
||||||
|
def action_send_skyroom_credentials(self, request, object_id: int):
|
||||||
|
event = Event.objects.get(pk=object_id)
|
||||||
|
queue_skyroom_credentials.delay(event.pk)
|
||||||
|
self.message_user(request, f"ارسال مشخصات اسکایروم برای رویداد '{event.title}' صف شد.", messages.SUCCESS)
|
||||||
|
return redirect(reverse_lazy("admin:events_event_changelist"))
|
||||||
|
|
||||||
|
@unfold_action(description="Send new Reminder")
|
||||||
|
def action_send_reminder_now(self, request, object_id: int):
|
||||||
|
event = Event.objects.get(pk=object_id)
|
||||||
|
send_event_reminder_task.delay(event.pk)
|
||||||
|
self.message_user(request, f"یادآوری برای رویداد '{event.title}' صف شد.", messages.SUCCESS)
|
||||||
|
return redirect(reverse_lazy("admin:events_event_changelist"))
|
||||||
|
|
||||||
|
@unfold_action(description="send new Announcement")
|
||||||
|
def action_send_announcement(self, request, object_id: int):
|
||||||
|
"""
|
||||||
|
این اکشن یک فرم میگیرد (عنوان/متن/وضعیتها) و با تمپلیت Unfold نشان داده میشود.
|
||||||
|
"""
|
||||||
|
form = AnnouncementForm(request.POST or None)
|
||||||
|
event = Event.objects.get(pk=object_id)
|
||||||
|
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
subject = form.cleaned_data["subject"]
|
||||||
|
body_html = form.cleaned_data["body_html"]
|
||||||
|
statuses = form.cleaned_data["statuses"] or None
|
||||||
|
queue_event_announcement.delay(event.pk, subject, body_html, statuses=statuses)
|
||||||
|
self.message_user(request, f"اطلاعیه برای رویداد '{event.title}' صف شد.", messages.SUCCESS)
|
||||||
|
return redirect(reverse_lazy("admin:events_event_changelist"))
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**self.admin_site.each_context(request),
|
||||||
|
"title": "ارسال اطلاعیه گروهی",
|
||||||
|
"opts": self.model._meta,
|
||||||
|
"form": form,
|
||||||
|
"action_name": "action_send_announcement",
|
||||||
|
"action_checkbox_name": ACTION_CHECKBOX_NAME,
|
||||||
|
}
|
||||||
|
return TemplateResponse(request, "forms/admin_announcement.html", context)
|
||||||
|
|
||||||
|
@unfold_action(description="Invite other users")
|
||||||
|
def action_invite_other_users(self, request, object_id: int):
|
||||||
|
event = Event.objects.get(pk=object_id)
|
||||||
|
queue_invites_to_non_registered_users.delay(event.pk)
|
||||||
|
self.message_user(request, f"دعوت برای شرکت در رویداد '{event.title}' صف شد.", messages.SUCCESS)
|
||||||
|
return redirect(reverse_lazy("admin:events_event_changelist"))
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(Registration)
|
||||||
|
class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
resource_class = RegistrationResource
|
||||||
|
list_display = (
|
||||||
|
'user',
|
||||||
|
'event',
|
||||||
|
'status',
|
||||||
|
'registered_at',
|
||||||
|
'ticket_id',
|
||||||
|
'discount_code',
|
||||||
|
'discount_amount',
|
||||||
|
'final_price',
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
'status',
|
||||||
|
'event',
|
||||||
|
'is_deleted',
|
||||||
|
'registered_at',
|
||||||
|
SoftDeleteListFilter
|
||||||
|
)
|
||||||
|
search_fields = ('user__username', 'user__email', 'user__first_name', 'user__last_name', 'event__title', 'ticket_id')
|
||||||
|
readonly_fields = (
|
||||||
|
'ticket_id',
|
||||||
|
'registered_at',
|
||||||
|
'confirmation_email_sent_at',
|
||||||
|
'cancellation_email_sent_at',
|
||||||
|
'discount_code',
|
||||||
|
'discount_amount',
|
||||||
|
'final_price',
|
||||||
|
'deleted_at',
|
||||||
|
)
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
(
|
||||||
|
'Registration Details',
|
||||||
|
{
|
||||||
|
'fields': (
|
||||||
|
'user',
|
||||||
|
'event',
|
||||||
|
'status',
|
||||||
|
'registered_at',
|
||||||
|
'ticket_id',
|
||||||
|
'confirmation_email_sent_at',
|
||||||
|
'cancellation_email_sent_at',
|
||||||
|
)
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
'Pricing & Discount',
|
||||||
|
{
|
||||||
|
'fields': ('discount_code', 'discount_amount', 'final_price'),
|
||||||
|
'classes': ('collapse',),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + [
|
||||||
|
'confirm_registrations',
|
||||||
|
'cancel_registrations',
|
||||||
|
'mark_attended',
|
||||||
|
'restore_registrations',
|
||||||
|
]
|
||||||
|
actions_row = [
|
||||||
|
'action_email_selected',
|
||||||
|
'action_send_skyroom_credentials',
|
||||||
|
]
|
||||||
|
|
||||||
|
@admin.action(description="Confirm selected registrations")
|
||||||
|
def confirm_registrations(self, request, queryset):
|
||||||
|
queryset.update(status=Registration.StatusChoices.CONFIRMED)
|
||||||
|
self.message_user(request, f"Confirmed {queryset.count()} registrations.")
|
||||||
|
|
||||||
|
@admin.action(description="Cancel selected registrations")
|
||||||
|
def cancel_registrations(self, request, queryset):
|
||||||
|
queryset.update(status=Registration.StatusChoices.CANCELLED)
|
||||||
|
self.message_user(request, f"Cancelled {queryset.count()} registrations.")
|
||||||
|
|
||||||
|
@admin.action(description="Mark selected registrations as attended")
|
||||||
|
def mark_attended(self, request, queryset):
|
||||||
|
queryset.update(status=Registration.StatusChoices.ATTENDED)
|
||||||
|
self.message_user(request, f"Marked {queryset.count()} registrations as attended.")
|
||||||
|
|
||||||
|
@admin.action(description="Restore selected registrations")
|
||||||
|
def restore_registrations(self, request, queryset):
|
||||||
|
for registration in queryset:
|
||||||
|
registration.restore()
|
||||||
|
self.message_user(request, f"Restored {queryset.count()} registrations.")
|
||||||
|
|
||||||
|
@unfold_action(description="send email to registrated user")
|
||||||
|
def action_email_selected(self, request, object_id: int):
|
||||||
|
"""
|
||||||
|
همان فرم اطلاعیه را میگیرد و به افراد انتخابشده ایمیل میزند.
|
||||||
|
برای نمایش فرم، از تمپلیت Unfold استفاده میکنیم.
|
||||||
|
"""
|
||||||
|
form = AnnouncementForm(request.POST or None)
|
||||||
|
registration = Registration.objects.get(id=object_id)
|
||||||
|
|
||||||
|
if request.method == "POST" and form.is_valid():
|
||||||
|
subject = form.cleaned_data["subject"]
|
||||||
|
body_html = form.cleaned_data["body_html"]
|
||||||
|
|
||||||
|
user = registration.user
|
||||||
|
ctx = {
|
||||||
|
"user": user,
|
||||||
|
"event": registration.event,
|
||||||
|
"body_html": body_html,
|
||||||
|
"event_url": f"{settings.FRONTEND_ROOT}events/{registration.event.slug}",
|
||||||
|
}
|
||||||
|
html = render_to_string("emails/event_announcement.html", ctx)
|
||||||
|
_send_html_email(subject, html, user.email)
|
||||||
|
|
||||||
|
self.message_user(request, f"ارسال ایمیل انجام شد.", messages.SUCCESS)
|
||||||
|
return redirect(reverse_lazy("admin:events_registration_changelist"))
|
||||||
|
|
||||||
|
context = {
|
||||||
|
**self.admin_site.each_context(request),
|
||||||
|
"title": "ارسال ایمیل به ثبتنامهای انتخابشده",
|
||||||
|
"form": AnnouncementForm(),
|
||||||
|
"opts": self.model._meta,
|
||||||
|
"action_name": "action_email_selected",
|
||||||
|
"action_checkbox_name": ACTION_CHECKBOX_NAME,
|
||||||
|
}
|
||||||
|
return TemplateResponse(request, "forms/admin_announcement.html", context)
|
||||||
|
|
||||||
|
@unfold_action(description="Send Skyroom Credentials")
|
||||||
|
def action_send_skyroom_credentials(self, request, object_id: int):
|
||||||
|
send_skyroom_credentials_individual_task.delay(object_id)
|
||||||
|
self.message_user(request, f"ارسال مشخصات اسکایروم به کاربر مربوطه صف شد.", messages.SUCCESS)
|
||||||
|
return redirect(reverse_lazy("admin:events_registration_changelist"))
|
||||||
|
|
||||||
|
|
||||||
|
from events.tasks import send_invite_to_user
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@admin.register(EventEmailLog)
|
||||||
|
class EventEmailLogAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
list_display = (
|
||||||
|
"id",
|
||||||
|
"event",
|
||||||
|
"user",
|
||||||
|
"user_email",
|
||||||
|
"kind",
|
||||||
|
"status",
|
||||||
|
"sent_at",
|
||||||
|
"created_at",
|
||||||
|
)
|
||||||
|
list_filter = (
|
||||||
|
"kind",
|
||||||
|
"status",
|
||||||
|
"event",
|
||||||
|
("sent_at", admin.EmptyFieldListFilter),
|
||||||
|
("error", admin.EmptyFieldListFilter),
|
||||||
|
SoftDeleteListFilter,
|
||||||
|
)
|
||||||
|
search_fields = (
|
||||||
|
"user__email",
|
||||||
|
"user__username",
|
||||||
|
"user__first_name",
|
||||||
|
"user__last_name",
|
||||||
|
"event__title",
|
||||||
|
)
|
||||||
|
autocomplete_fields = ("event", "user")
|
||||||
|
date_hierarchy = "created_at"
|
||||||
|
ordering = ("-created_at",)
|
||||||
|
list_per_page = 50
|
||||||
|
list_select_related = ("event", "user")
|
||||||
|
|
||||||
|
# چون این مدل برای ایدمپوتنسی حیاتی است، ویرایش دستی را محدود میکنیم
|
||||||
|
readonly_fields = (
|
||||||
|
"event",
|
||||||
|
"user",
|
||||||
|
"kind",
|
||||||
|
"status",
|
||||||
|
"error",
|
||||||
|
"sent_at",
|
||||||
|
"created_at",
|
||||||
|
"updated_at",
|
||||||
|
)
|
||||||
|
fields = readonly_fields
|
||||||
|
|
||||||
|
def has_add_permission(self, request):
|
||||||
|
return False
|
||||||
|
|
||||||
|
def has_change_permission(self, request, obj=None):
|
||||||
|
return True
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + [
|
||||||
|
'resend_selected_emails'
|
||||||
|
]
|
||||||
|
|
||||||
|
@admin.display(description="Email", ordering="user__email")
|
||||||
|
def user_email(self, obj):
|
||||||
|
return obj.user.email or "—"
|
||||||
|
|
||||||
|
@admin.action(description="ارسال مجدد ایمیل برای رکوردهای انتخابشده")
|
||||||
|
def resend_selected_emails(self, request, queryset):
|
||||||
|
"""
|
||||||
|
رکوردهای SENT را اسکیپ میکند، بقیه را به وضعیت pending برمیگرداند
|
||||||
|
و تسک ارسال تکی را در صف میگذارد (ایدِمپوتنت).
|
||||||
|
"""
|
||||||
|
queued = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for log in queryset.select_related("event", "user"):
|
||||||
|
if log.status == EventEmailLog.STATUS_SENT:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# برگرداندن به pending و پاک کردن خطا
|
||||||
|
if log.status != EventEmailLog.STATUS_PENDING or log.error:
|
||||||
|
log.status = EventEmailLog.STATUS_PENDING
|
||||||
|
log.error = ""
|
||||||
|
log.save(update_fields=["status", "error", "updated_at"])
|
||||||
|
|
||||||
|
# صف کردن تسک اتمی
|
||||||
|
send_invite_to_user.delay(log.event_id, log.user_id)
|
||||||
|
queued += 1
|
||||||
|
|
||||||
|
if queued:
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
"%(n)d مورد در صف ارسال قرار گرفت." % {"n": queued},
|
||||||
|
level=messages.SUCCESS,
|
||||||
|
)
|
||||||
|
if skipped:
|
||||||
|
self.message_user(
|
||||||
|
request,
|
||||||
|
"%(n)d مورد قبلاً ارسال شده بود و نادیده گرفته شد." % {"n": skipped},
|
||||||
|
level=messages.WARNING,
|
||||||
|
)
|
||||||
25
backend/events/admin_forms.py
Normal file
25
backend/events/admin_forms.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
from django import forms
|
||||||
|
|
||||||
|
from unfold.widgets import UnfoldAdminTextInputWidget, UnfoldAdminTextareaWidget
|
||||||
|
|
||||||
|
from events.models import Registration
|
||||||
|
|
||||||
|
|
||||||
|
class AnnouncementForm(forms.Form):
|
||||||
|
subject = forms.CharField(
|
||||||
|
label="Subject",
|
||||||
|
max_length=200,
|
||||||
|
widget=UnfoldAdminTextInputWidget,
|
||||||
|
)
|
||||||
|
body_html = forms.CharField(
|
||||||
|
label="Text (HTML or plain-text)",
|
||||||
|
widget=UnfoldAdminTextareaWidget,
|
||||||
|
help_text="you can enter either HTML or plain-text."
|
||||||
|
)
|
||||||
|
statuses = forms.MultipleChoiceField(
|
||||||
|
label="Statuses to sent",
|
||||||
|
required=False,
|
||||||
|
choices=Registration.StatusChoices.choices,
|
||||||
|
initial=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED],
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
)
|
||||||
6
backend/events/apps.py
Normal file
6
backend/events/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class EventsConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'events'
|
||||||
379
backend/events/fixtures/events.json
Normal file
379
backend/events/fixtures/events.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
60
backend/events/migrations/0001_initial.py
Normal file
60
backend/events/migrations/0001_initial.py
Normal file
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
27
backend/events/migrations/0002_initial.py
Normal file
27
backend/events/migrations/0002_initial.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
39
backend/events/migrations/0003_initial.py
Normal file
39
backend/events/migrations/0003_initial.py
Normal file
@@ -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'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
backend/events/migrations/0008_alter_eventemaillog_kind.py
Normal file
18
backend/events/migrations/0008_alter_eventemaillog_kind.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -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),
|
||||||
|
]
|
||||||
22
backend/events/migrations/0011_eventemaillog_context_hash.py
Normal file
22
backend/events/migrations/0011_eventemaillog_context_hash.py
Normal file
@@ -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')},
|
||||||
|
),
|
||||||
|
]
|
||||||
18
backend/events/migrations/0012_alter_eventemaillog_kind.py
Normal file
18
backend/events/migrations/0012_alter_eventemaillog_kind.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/events/migrations/__init__.py
Normal file
0
backend/events/migrations/__init__.py
Normal file
269
backend/events/models.py
Normal file
269
backend/events/models.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
from django.utils.text import slugify
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import markdown
|
||||||
|
from location_field.models.plain import PlainLocationField as LocationField
|
||||||
|
|
||||||
|
from utils.models import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Event(BaseModel):
|
||||||
|
class TypeChoices(models.TextChoices):
|
||||||
|
ONLINE = 'online', 'آنلاین'
|
||||||
|
ON_SITE = 'on_site', 'حضوری'
|
||||||
|
HYBRID = 'hybrid', 'آنلاین/حضوری'
|
||||||
|
|
||||||
|
class StatusChoices(models.TextChoices):
|
||||||
|
DRAFT = 'draft', 'Draft'
|
||||||
|
PUBLISHED = 'published', 'Published'
|
||||||
|
CANCELLED = 'cancelled', 'Cancelled'
|
||||||
|
COMPLETED = 'completed', 'Completed'
|
||||||
|
|
||||||
|
title = models.CharField(max_length=255)
|
||||||
|
slug = models.SlugField(max_length=255, unique=True, blank=True)
|
||||||
|
description = models.TextField(help_text="Event description in Markdown format")
|
||||||
|
|
||||||
|
start_time = models.DateTimeField()
|
||||||
|
end_time = models.DateTimeField()
|
||||||
|
|
||||||
|
address = models.CharField(max_length=255, blank=True, null=True, help_text="Physical address or venue name")
|
||||||
|
location = LocationField(based_fields=['address'], zoom=15, blank=True, null=True,
|
||||||
|
help_text="Select location on map")
|
||||||
|
|
||||||
|
event_type = models.CharField(max_length=10, choices=TypeChoices.choices, default=TypeChoices.ON_SITE)
|
||||||
|
online_link = models.URLField(max_length=500, blank=True, null=True,
|
||||||
|
help_text="Link for online events (e.g., Zoom, Google Meet)")
|
||||||
|
|
||||||
|
status = models.CharField(max_length=10, choices=StatusChoices.choices, default=StatusChoices.DRAFT)
|
||||||
|
capacity = models.PositiveIntegerField(null=True, blank=True,
|
||||||
|
help_text="Maximum number of attendees (leave blank for unlimited)")
|
||||||
|
|
||||||
|
price = models.IntegerField(default=0, help_text="Price of the event. Leave blank for free events.")
|
||||||
|
|
||||||
|
registration_start_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
registration_end_date = models.DateTimeField(null=True, blank=True)
|
||||||
|
featured_image = models.ImageField(upload_to='events/featured/', null=True, blank=True)
|
||||||
|
gallery_images = models.ManyToManyField('gallery.Gallery', blank=True, related_name='event_galleries',
|
||||||
|
help_text="Images taken during or related to the event.")
|
||||||
|
|
||||||
|
registration_success_markdown = models.TextField(
|
||||||
|
blank=True, null=True,
|
||||||
|
help_text="Optional markdown shown to users after a successful registration."
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-start_time']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['status', 'start_time']),
|
||||||
|
models.Index(fields=['event_type']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
if not self.slug:
|
||||||
|
self.slug = slugify(self.title)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def description_html(self):
|
||||||
|
"""Convert markdown description to HTML"""
|
||||||
|
return markdown.markdown(
|
||||||
|
self.description,
|
||||||
|
extensions=[
|
||||||
|
'markdown.extensions.extra',
|
||||||
|
'markdown.extensions.toc',
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_registration_open(self):
|
||||||
|
now = timezone.now()
|
||||||
|
return (self.registration_start_date is None or now >= self.registration_start_date) and \
|
||||||
|
(self.registration_end_date is None or now <= self.registration_end_date)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_attendees_count(self):
|
||||||
|
"""Count confirmed attendees"""
|
||||||
|
return self.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED], is_deleted=False).count()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_available_slots(self):
|
||||||
|
"""Check whether registration slots are available, treating None as unlimited capacity."""
|
||||||
|
if self.capacity is None:
|
||||||
|
return True
|
||||||
|
return self.current_attendees_count < self.capacity
|
||||||
|
|
||||||
|
|
||||||
|
class Registration(BaseModel):
|
||||||
|
class StatusChoices(models.TextChoices):
|
||||||
|
PENDING = 'pending', 'Pending'
|
||||||
|
CONFIRMED = 'confirmed', 'Confirmed'
|
||||||
|
CANCELLED = 'cancelled', 'Cancelled'
|
||||||
|
ATTENDED = 'attended', 'Attended'
|
||||||
|
|
||||||
|
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='registrations')
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='event_registrations')
|
||||||
|
registered_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
status = models.CharField(max_length=10, choices=StatusChoices.choices,
|
||||||
|
default=StatusChoices.PENDING)
|
||||||
|
ticket_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||||
|
|
||||||
|
confirmation_email_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
cancellation_email_sent_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
discount_code = models.ForeignKey(
|
||||||
|
"payments.DiscountCode",
|
||||||
|
null=True,
|
||||||
|
blank=True,
|
||||||
|
on_delete=models.SET_NULL,
|
||||||
|
related_name="registrations",
|
||||||
|
)
|
||||||
|
discount_amount = models.PositiveIntegerField(default=0)
|
||||||
|
final_price = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-registered_at']
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=['event', 'status']),
|
||||||
|
models.Index(fields=['user']),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.user.username} registered for {self.event.title}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status_label(self):
|
||||||
|
"""Human-readable label for the current registration status."""
|
||||||
|
return self.get_status_display()
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
# detect create vs update
|
||||||
|
is_create = self._state.adding
|
||||||
|
old_status = None
|
||||||
|
|
||||||
|
if not is_create and self.pk:
|
||||||
|
old_status = (
|
||||||
|
self.__class__.objects.only("status").get(pk=self.pk).status
|
||||||
|
)
|
||||||
|
|
||||||
|
# save first (so we have a pk + final values)
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
# 1) on create -> send confirmation if pending/confirmed (and not sent before)
|
||||||
|
if is_create and self.status == self.StatusChoices.CONFIRMED and not self.confirmation_email_sent_at:
|
||||||
|
# lazy import to avoid circular import
|
||||||
|
from events.tasks import send_registration_confirmation_email
|
||||||
|
send_registration_confirmation_email.delay(str(self.pk))
|
||||||
|
self.confirmation_email_sent_at = timezone.now()
|
||||||
|
super().save(update_fields=["confirmation_email_sent_at"])
|
||||||
|
|
||||||
|
# 2) status changed -> cancelled
|
||||||
|
if (not is_create) and (old_status != self.StatusChoices.CANCELLED) and (self.status == self.StatusChoices.CANCELLED) and (not self.cancellation_email_sent_at):
|
||||||
|
from events.tasks import send_registration_cancellation_email
|
||||||
|
send_registration_cancellation_email.delay(str(self.pk))
|
||||||
|
self.cancellation_email_sent_at = timezone.now()
|
||||||
|
super().save(update_fields=["cancellation_email_sent_at"])
|
||||||
|
|
||||||
|
# 3) status changed -> confirmed (if not sent before)
|
||||||
|
if (not is_create) and (old_status != self.StatusChoices.CONFIRMED) and (self.status == self.StatusChoices.CONFIRMED) and (not self.confirmation_email_sent_at):
|
||||||
|
from events.tasks import send_registration_confirmation_email
|
||||||
|
send_registration_confirmation_email.delay(str(self.pk))
|
||||||
|
self.confirmation_email_sent_at = timezone.now()
|
||||||
|
super().save(update_fields=["confirmation_email_sent_at"])
|
||||||
|
|
||||||
|
|
||||||
|
class EventEmailLog(BaseModel):
|
||||||
|
class KindChoices(models.TextChoices):
|
||||||
|
INVITE_NON_REGISTERED = "invite_non_registered", "Invite non-registered users"
|
||||||
|
SKYROOM_CREDENTIALS = "send_skyroom_credentials", "Skyroom credentials"
|
||||||
|
EVENT_ANNOUNCEMENT = "send_event_announcement", "Event announcement"
|
||||||
|
EVENT_ANNOUNCEMENT2 = "send_event_announcement2", "Event announcement 2"
|
||||||
|
EVENT_ANNOUNCEMENT3 = "send_event_announcement3", "Event announcement 3"
|
||||||
|
EVENT_REMINDER = "send_event_reminder", "Event reminder"
|
||||||
|
|
||||||
|
class StatusChoices(models.TextChoices):
|
||||||
|
PENDING = "pending", "Pending"
|
||||||
|
SENT = "sent", "Sent"
|
||||||
|
FAILED = "failed", "Failed"
|
||||||
|
|
||||||
|
KIND_INVITE_NON_REGISTERED = KindChoices.INVITE_NON_REGISTERED
|
||||||
|
KIND_SKYROOM_CREDENTIALS = KindChoices.SKYROOM_CREDENTIALS
|
||||||
|
KIND_EVENT_ANNOUNCEMENT = KindChoices.EVENT_ANNOUNCEMENT
|
||||||
|
KIND_EVENT_ANNOUNCEMENT2 = KindChoices.EVENT_ANNOUNCEMENT2
|
||||||
|
KIND_EVENT_ANNOUNCEMENT3 = KindChoices.EVENT_ANNOUNCEMENT3
|
||||||
|
KIND_EVENT_REMINDER = KindChoices.EVENT_REMINDER
|
||||||
|
KIND_CHOICES = KindChoices.choices
|
||||||
|
|
||||||
|
STATUS_PENDING = StatusChoices.PENDING
|
||||||
|
STATUS_SENT = StatusChoices.SENT
|
||||||
|
STATUS_FAILED = StatusChoices.FAILED
|
||||||
|
STATUS_CHOICES = StatusChoices.choices
|
||||||
|
|
||||||
|
event = models.ForeignKey('events.Event', on_delete=models.CASCADE, related_name='email_logs')
|
||||||
|
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='email_logs')
|
||||||
|
kind = models.CharField(max_length=64, choices=KIND_CHOICES)
|
||||||
|
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING)
|
||||||
|
error = models.TextField(blank=True, null=True)
|
||||||
|
sent_at = models.DateTimeField(blank=True, null=True)
|
||||||
|
context_hash = models.CharField(max_length=64, blank=True, null=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
unique_together = ("event", "user", "kind", "context_hash")
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["event", "kind", "status"]),
|
||||||
|
models.Index(fields=["user", "kind", "status"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.event.id} - {self.user.id} - {self.kind} - {self.status}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hash_context(context):
|
||||||
|
if context is None:
|
||||||
|
return None
|
||||||
|
if not isinstance(context, str):
|
||||||
|
context = str(context)
|
||||||
|
return hashlib.sha256(context.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def claim(cls, *, event_id, user_id, kind, context=None):
|
||||||
|
context_hash = cls._hash_context(context)
|
||||||
|
log, created = cls.objects.get_or_create(
|
||||||
|
event_id=event_id,
|
||||||
|
user_id=user_id,
|
||||||
|
kind=kind,
|
||||||
|
context_hash=context_hash,
|
||||||
|
defaults={"status": cls.STATUS_PENDING},
|
||||||
|
)
|
||||||
|
if not created and log.status in (cls.STATUS_PENDING, cls.STATUS_SENT):
|
||||||
|
return log, True
|
||||||
|
if not created:
|
||||||
|
log._commit_status(cls.STATUS_PENDING, error="")
|
||||||
|
return log, False
|
||||||
|
|
||||||
|
def _commit_status(self, status, *, error="", sent_at=None):
|
||||||
|
self.status = status
|
||||||
|
self.error = error
|
||||||
|
update_fields = ["status", "error"]
|
||||||
|
if status == self.STATUS_SENT:
|
||||||
|
self.sent_at = sent_at or timezone.now()
|
||||||
|
update_fields.append("sent_at")
|
||||||
|
elif self.sent_at is not None:
|
||||||
|
self.sent_at = None
|
||||||
|
update_fields.append("sent_at")
|
||||||
|
if hasattr(self, "updated_at"):
|
||||||
|
update_fields.append("updated_at")
|
||||||
|
self.save(update_fields=update_fields)
|
||||||
|
|
||||||
|
def mark_sent(self):
|
||||||
|
self._commit_status(self.STATUS_SENT)
|
||||||
|
|
||||||
|
def mark_failed(self, error):
|
||||||
|
self._commit_status(self.STATUS_FAILED, error=error)
|
||||||
86
backend/events/resources.py
Normal file
86
backend/events/resources.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
from import_export import resources, fields
|
||||||
|
from import_export.widgets import ForeignKeyWidget, ManyToManyWidget
|
||||||
|
|
||||||
|
from events.models import Event, Registration
|
||||||
|
from users.models import User
|
||||||
|
from gallery.models import Gallery
|
||||||
|
from payments.models import DiscountCode
|
||||||
|
|
||||||
|
class EventResource(resources.ModelResource):
|
||||||
|
gallery_images = fields.Field(
|
||||||
|
column_name='gallery_images',
|
||||||
|
attribute='gallery_images',
|
||||||
|
widget=ManyToManyWidget(Gallery, field='title', separator='|')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Event
|
||||||
|
fields = (
|
||||||
|
'id', 'title', 'slug', 'description', 'start_time', 'end_time',
|
||||||
|
'event_type', 'address', 'location', 'online_link', 'status',
|
||||||
|
'capacity', 'price', 'registration_start_date', 'registration_end_date',
|
||||||
|
'featured_image', 'gallery_images', 'created_at', 'updated_at',
|
||||||
|
'is_deleted', 'deleted_at'
|
||||||
|
)
|
||||||
|
export_order = fields
|
||||||
|
|
||||||
|
class RegistrationResource(resources.ModelResource):
|
||||||
|
"""Export registrations with user attributes and shortened ticket identifiers."""
|
||||||
|
|
||||||
|
event = fields.Field(
|
||||||
|
column_name='event',
|
||||||
|
attribute='event',
|
||||||
|
widget=ForeignKeyWidget(Event, 'title')
|
||||||
|
)
|
||||||
|
user_username = fields.Field(
|
||||||
|
column_name='user_username',
|
||||||
|
attribute='user',
|
||||||
|
widget=ForeignKeyWidget(User, 'username')
|
||||||
|
)
|
||||||
|
user_email = fields.Field(
|
||||||
|
column_name='user_email',
|
||||||
|
attribute='user',
|
||||||
|
widget=ForeignKeyWidget(User, 'email')
|
||||||
|
)
|
||||||
|
user_first_name = fields.Field(
|
||||||
|
column_name='user_first_name',
|
||||||
|
attribute='user',
|
||||||
|
widget=ForeignKeyWidget(User, 'first_name')
|
||||||
|
)
|
||||||
|
user_last_name = fields.Field(
|
||||||
|
column_name='user_last_name',
|
||||||
|
attribute='user',
|
||||||
|
widget=ForeignKeyWidget(User, 'last_name')
|
||||||
|
)
|
||||||
|
discount_code = fields.Field(
|
||||||
|
column_name='discount_code',
|
||||||
|
attribute='discount_code',
|
||||||
|
widget=ForeignKeyWidget(DiscountCode, 'code')
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Registration
|
||||||
|
fields = (
|
||||||
|
'id',
|
||||||
|
'event',
|
||||||
|
'user_username',
|
||||||
|
'user_email',
|
||||||
|
'user_first_name',
|
||||||
|
'user_last_name',
|
||||||
|
'registered_at',
|
||||||
|
'status',
|
||||||
|
'ticket_id',
|
||||||
|
'discount_code',
|
||||||
|
'discount_amount',
|
||||||
|
'final_price',
|
||||||
|
'created_at',
|
||||||
|
'updated_at',
|
||||||
|
'is_deleted',
|
||||||
|
'deleted_at',
|
||||||
|
)
|
||||||
|
export_order = fields
|
||||||
|
|
||||||
|
def dehydrate_ticket_id(self, obj):
|
||||||
|
"""Limit ticket identifiers to eight characters in exports."""
|
||||||
|
val = getattr(obj, 'ticket_id', '')
|
||||||
|
return str(val)[:8] if val else ''
|
||||||
584
backend/events/tasks.py
Normal file
584
backend/events/tasks.py
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
from django.core.mail import EmailMultiAlternatives
|
||||||
|
from django.template.loader import render_to_string
|
||||||
|
from django.utils.html import strip_tags
|
||||||
|
from django.conf import settings
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from celery import shared_task, group
|
||||||
|
from celery.exceptions import SoftTimeLimitExceeded
|
||||||
|
import markdown
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from users.models import User
|
||||||
|
from events.models import Event, Registration, EventEmailLog
|
||||||
|
from utils.templatetags.jalali import fa_digits, jdate
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS = 30
|
||||||
|
ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS = 45
|
||||||
|
|
||||||
|
@shared_task(bind=True, max_retries=3)
|
||||||
|
def send_registration_confirmation_email(self, registration_pk: str):
|
||||||
|
"""Send a registration confirmation email, loading the model lazily to avoid circular imports."""
|
||||||
|
try:
|
||||||
|
from .models import Registration
|
||||||
|
reg = (
|
||||||
|
Registration.objects
|
||||||
|
.select_related("event", "user")
|
||||||
|
.get(pk=registration_pk)
|
||||||
|
)
|
||||||
|
|
||||||
|
user_email = getattr(reg.user, "email", None)
|
||||||
|
if not user_email:
|
||||||
|
return
|
||||||
|
|
||||||
|
success_md = reg.event.registration_success_markdown or ""
|
||||||
|
success_html = markdown.markdown(
|
||||||
|
success_md,
|
||||||
|
extensions=["extra", "sane_lists", "toc"]
|
||||||
|
) if success_md else ""
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"user": reg.user,
|
||||||
|
"event": reg.event,
|
||||||
|
"registration": reg,
|
||||||
|
"success_html": success_html,
|
||||||
|
}
|
||||||
|
|
||||||
|
subject = f"تأیید ثبتنام شما در {reg.event.title}"
|
||||||
|
html_body = render_to_string("emails/event_registration_confirmation.html", context)
|
||||||
|
plain_body = strip_tags(html_body)
|
||||||
|
|
||||||
|
message = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=plain_body,
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||||
|
to=[user_email],
|
||||||
|
)
|
||||||
|
message.attach_alternative(html_body, "text/html")
|
||||||
|
message.send(fail_silently=False)
|
||||||
|
logger.info(f"Event Confirm Registration email sent to {reg.user.email}")
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send event registration email: {exc}")
|
||||||
|
raise self.retry(exc=exc, countdown=60)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, max_retries=3)
|
||||||
|
def send_registration_cancellation_email(self, registration_pk: str):
|
||||||
|
try:
|
||||||
|
from .models import Registration
|
||||||
|
reg = (
|
||||||
|
Registration.objects
|
||||||
|
.select_related("event", "user")
|
||||||
|
.get(pk=registration_pk)
|
||||||
|
)
|
||||||
|
|
||||||
|
user_email = getattr(reg.user, "email", None)
|
||||||
|
if not user_email:
|
||||||
|
return
|
||||||
|
|
||||||
|
context = {
|
||||||
|
"user": reg.user,
|
||||||
|
"event": reg.event,
|
||||||
|
"registration": reg,
|
||||||
|
}
|
||||||
|
|
||||||
|
subject = f"لغو ثبتنام شما در {reg.event.title}"
|
||||||
|
html_body = render_to_string("emails/event_registration_cancellation.html", context)
|
||||||
|
plain_body = strip_tags(html_body)
|
||||||
|
|
||||||
|
message = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=plain_body,
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||||
|
to=[user_email],
|
||||||
|
)
|
||||||
|
message.attach_alternative(html_body, "text/html")
|
||||||
|
message.send(fail_silently=False)
|
||||||
|
logger.info(f"Event Confirm Registration email sent to {reg.user.email}")
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send event registration email: {exc}")
|
||||||
|
raise self.retry(exc=exc, countdown=60)
|
||||||
|
|
||||||
|
|
||||||
|
def _event_recipients(event, statuses=None, only_verified=True):
|
||||||
|
qs = Registration.objects.filter(event=event, is_deleted=False)
|
||||||
|
if statuses:
|
||||||
|
qs = qs.filter(status__in=statuses)
|
||||||
|
if only_verified:
|
||||||
|
qs = qs.filter(user__is_email_verified=True)
|
||||||
|
|
||||||
|
qs = qs.exclude(user__email__isnull=True).exclude(user__email="")
|
||||||
|
return qs.select_related("user")
|
||||||
|
|
||||||
|
|
||||||
|
def _send_html_email(subject, html_body, to_email):
|
||||||
|
text_body = strip_tags(html_body)
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_body,
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||||
|
to=[to_email],
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html_body, "text/html")
|
||||||
|
msg.send()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_email_context(*parts):
|
||||||
|
values = [str(part) for part in parts if part not in (None, "")]
|
||||||
|
return "|".join(values) if values else None
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_kwargs={"max_retries": 3}, soft_time_limit=60)
|
||||||
|
def send_skyroom_credentials_individual_task(self, reg_id: int):
|
||||||
|
"""
|
||||||
|
ارسال نامکاربری/رمز برای اسکایروم
|
||||||
|
- username = user.email
|
||||||
|
- password = registration.ticket_id[:8]
|
||||||
|
- url = event.online_link (اگر لینک در فیلد online_link ذخیره شده باشد)
|
||||||
|
"""
|
||||||
|
r = Registration.objects.get(pk=reg_id)
|
||||||
|
event = r.event
|
||||||
|
user = r.user
|
||||||
|
sky_user = user.email.strip().split('@')[0]
|
||||||
|
sky_pass = str(r.ticket_id)[:8]
|
||||||
|
skyroom_url = event.online_link
|
||||||
|
try:
|
||||||
|
ctx = {
|
||||||
|
"user": user,
|
||||||
|
"event": event,
|
||||||
|
"skyroom_url": skyroom_url,
|
||||||
|
"sky_username": sky_user,
|
||||||
|
"sky_password": sky_pass,
|
||||||
|
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
|
||||||
|
}
|
||||||
|
subject = f"اطلاعات دسترسی اسکایروم - {event.title}"
|
||||||
|
html = render_to_string("emails/skyroom_credentials.html", ctx)
|
||||||
|
text_body = strip_tags(html)
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_body,
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||||
|
to=[user.email],
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html, "text/html")
|
||||||
|
msg.send()
|
||||||
|
logger.info(f'Skyroom Credentials for Event "{event.title}" sent to {user.email}')
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error(f"Failed to send skyroom credentials email: {exc}")
|
||||||
|
raise self.retry(exc=exc, countdown=60)
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True)
|
||||||
|
def send_event_reminder_task(self, event_id: int):
|
||||||
|
"""
|
||||||
|
یادآوری رویداد (ارسال الان؛ برای ارسال خودکار یک روز قبل، یک beat job بسازید)
|
||||||
|
"""
|
||||||
|
event = Event.objects.get(pk=event_id)
|
||||||
|
regs = (
|
||||||
|
_event_recipients(event, statuses=["confirmed", "attended"])
|
||||||
|
.select_related("user", "event")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
reg_ids = list(regs.values_list("id", flat=True))
|
||||||
|
|
||||||
|
job = group(send_event_reminder_to_user.s(event_id, rid) for rid in reg_ids)
|
||||||
|
res = job.apply_async()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
'Queued %s event reminder emails for event "%s" (group_id=%s)',
|
||||||
|
len(reg_ids),
|
||||||
|
event.title,
|
||||||
|
res.id,
|
||||||
|
)
|
||||||
|
return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=(Exception,),
|
||||||
|
retry_backoff=True,
|
||||||
|
retry_jitter=True,
|
||||||
|
retry_kwargs={"max_retries": 3},
|
||||||
|
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
|
||||||
|
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
|
||||||
|
)
|
||||||
|
def send_event_reminder_to_user(self, event_id: int, registration_id: int):
|
||||||
|
"""
|
||||||
|
Send reminder email to a single registration; safe to retry without duplicating emails.
|
||||||
|
"""
|
||||||
|
user = None
|
||||||
|
log = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
|
||||||
|
user = r.user
|
||||||
|
event = r.event
|
||||||
|
|
||||||
|
to_email = (user.email or "").strip()
|
||||||
|
if not to_email:
|
||||||
|
return {"skipped": True, "status": "no_email"}
|
||||||
|
|
||||||
|
context_key = _build_email_context(
|
||||||
|
"event_reminder",
|
||||||
|
event.slug or event.id,
|
||||||
|
event.start_time,
|
||||||
|
)
|
||||||
|
log, skip = EventEmailLog.claim(
|
||||||
|
event_id=event_id,
|
||||||
|
user_id=user.id,
|
||||||
|
kind=EventEmailLog.KIND_EVENT_REMINDER,
|
||||||
|
context=context_key,
|
||||||
|
)
|
||||||
|
if skip:
|
||||||
|
return {"skipped": True, "status": log.status}
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"user": user,
|
||||||
|
"event": event,
|
||||||
|
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
|
||||||
|
}
|
||||||
|
|
||||||
|
subject = f"یادآوری رویداد: {event.title}"
|
||||||
|
html = render_to_string("emails/event_reminder.html", ctx)
|
||||||
|
text_body = strip_tags(html)
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_body,
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||||
|
to=[to_email],
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html, "text/html")
|
||||||
|
msg.send()
|
||||||
|
|
||||||
|
log.mark_sent()
|
||||||
|
logger.info('Event reminder for "%s" sent to %s', event.title, to_email)
|
||||||
|
return f"Email sent to {to_email}"
|
||||||
|
|
||||||
|
except SoftTimeLimitExceeded:
|
||||||
|
if log:
|
||||||
|
log.mark_failed("Soft time limit exceeded")
|
||||||
|
logger.warning(
|
||||||
|
"Soft time limit exceeded (event_id=%s, registration_id=%s)",
|
||||||
|
event_id,
|
||||||
|
registration_id,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
if log:
|
||||||
|
log.mark_failed(str(exc))
|
||||||
|
logger.error(
|
||||||
|
"Failed to send event reminder email: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True)
|
||||||
|
def queue_event_announcement(self, event_id: int, subject: str, body_html: str, statuses=None):
|
||||||
|
"""
|
||||||
|
تسک مادر: ثبتنامهای هدف را پیدا میکند و برای هر Registration یک تسک کوچک میسازد.
|
||||||
|
"""
|
||||||
|
event = Event.objects.get(pk=event_id)
|
||||||
|
|
||||||
|
# محدوده مخاطبان: اگر statuses داده نشد، همان پیشفرض قبلی شما
|
||||||
|
statuses = statuses or ["confirmed", "attended", "pending"]
|
||||||
|
|
||||||
|
regs = (
|
||||||
|
_event_recipients(event, statuses=statuses)
|
||||||
|
.select_related("user", "event")
|
||||||
|
.exclude(user__email__isnull=True)
|
||||||
|
.exclude(user__email="")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
reg_ids = list(regs.values_list("id", flat=True))
|
||||||
|
|
||||||
|
# ساخت group از تسکهای کوچک؛ هر کدام فقط یک ایمیل ارسال میکند
|
||||||
|
job = group(
|
||||||
|
send_event_announcement_to_user.s(event_id, rid, subject, body_html)
|
||||||
|
for rid in reg_ids
|
||||||
|
)
|
||||||
|
|
||||||
|
# اگر نتیجهها لازم نیست: CELERY_TASK_IGNORE_RESULT = True
|
||||||
|
res = job.apply_async()
|
||||||
|
logger.info(
|
||||||
|
'Queued %s event-announcement emails for event "%s" (group_id=%s)',
|
||||||
|
len(reg_ids), event.title, res.id
|
||||||
|
)
|
||||||
|
return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id}
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=(Exception,),
|
||||||
|
retry_backoff=True,
|
||||||
|
retry_jitter=True,
|
||||||
|
retry_kwargs={"max_retries": 3},
|
||||||
|
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
|
||||||
|
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
|
||||||
|
)
|
||||||
|
def send_event_announcement_to_user(self, event_id: int, registration_id: int, subject: str, body_html: str):
|
||||||
|
"""
|
||||||
|
تسک کوچک و اتمی: ارسال ایمیل اعلان رویداد برای یک Registration.
|
||||||
|
با لاگ ایدمپوتنسی تا ارسال تکراری نداشته باشیم.
|
||||||
|
"""
|
||||||
|
user = None
|
||||||
|
log = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
# از Registration میگیریم تا یک کوئری کمتر به Event بزنیم
|
||||||
|
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
|
||||||
|
user = r.user
|
||||||
|
event = r.event
|
||||||
|
|
||||||
|
context_key = _build_email_context(
|
||||||
|
"event_announcement3",
|
||||||
|
event.slug or event.id,
|
||||||
|
subject,
|
||||||
|
body_html,
|
||||||
|
)
|
||||||
|
log, skip = EventEmailLog.claim(
|
||||||
|
event_id=event_id,
|
||||||
|
user_id=user.id,
|
||||||
|
kind=EventEmailLog.KIND_EVENT_ANNOUNCEMENT3,
|
||||||
|
context=context_key,
|
||||||
|
)
|
||||||
|
if skip:
|
||||||
|
return {"skipped": True, "status": log.status}
|
||||||
|
|
||||||
|
# کانتکست رندر ایمیل: body_html مستقیم داخل تمپلیت شما اینجکت میشود
|
||||||
|
ctx = {
|
||||||
|
"user": user,
|
||||||
|
"event": event,
|
||||||
|
"body_html": body_html,
|
||||||
|
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
|
||||||
|
}
|
||||||
|
|
||||||
|
html = render_to_string("emails/event_announcement.html", ctx)
|
||||||
|
text_body = strip_tags(html)
|
||||||
|
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_body,
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||||
|
to=[user.email],
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html, "text/html")
|
||||||
|
msg.send()
|
||||||
|
|
||||||
|
log.mark_sent()
|
||||||
|
|
||||||
|
logger.info('Event announcement for "%s" sent to %s', event.title, user.email)
|
||||||
|
return f"Email sent to {user.email}"
|
||||||
|
|
||||||
|
except SoftTimeLimitExceeded:
|
||||||
|
if log:
|
||||||
|
log.mark_failed("Soft time limit exceeded")
|
||||||
|
logger.warning("Soft time limit exceeded (event_id=%s, registration_id=%s)", event_id, registration_id)
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
if log:
|
||||||
|
log.mark_failed(str(exc))
|
||||||
|
logger.error("Failed to send event announcement email: %s", exc, exc_info=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _event_url(event):
|
||||||
|
root = getattr(settings, "FRONTEND_ROOT", "/")
|
||||||
|
slug_or_id = getattr(event, "slug", None) or event.id
|
||||||
|
return f"{root}events/{slug_or_id}"
|
||||||
|
|
||||||
|
@shared_task(bind=True)
|
||||||
|
def queue_invites_to_non_registered_users(self, event_id: int, only_verified=True, only_active=True):
|
||||||
|
"""
|
||||||
|
تسک مادر: فقط کاربرها را پیدا میکند و برای هر نفر یک تسک کوچک میسازد.
|
||||||
|
"""
|
||||||
|
event = Event.objects.get(pk=event_id)
|
||||||
|
|
||||||
|
qs = User.objects.all()
|
||||||
|
if only_verified:
|
||||||
|
qs = qs.filter(is_email_verified=True)
|
||||||
|
if only_active:
|
||||||
|
qs = qs.filter(is_active=True)
|
||||||
|
|
||||||
|
# کسانی که برای این ایونت ثبتنام نکردهاند
|
||||||
|
qs = qs.exclude(event_registrations__event_id=event_id) \
|
||||||
|
.exclude(email__isnull=True).exclude(email="") \
|
||||||
|
.distinct()
|
||||||
|
|
||||||
|
user_ids = list(qs.values_list("id", flat=True))
|
||||||
|
|
||||||
|
# گَروهِ تسکهای کوچک
|
||||||
|
job = group(send_invite_to_user.s(event_id, uid) for uid in user_ids)
|
||||||
|
res = job.apply_async()
|
||||||
|
return {"event_id": event_id, "queued": len(user_ids), "group_id": res.id}
|
||||||
|
|
||||||
|
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_jitter=True, retry_kwargs={"max_retries": 3}, time_limit=60)
|
||||||
|
def send_invite_to_user(self, event_id: int, user_id: int):
|
||||||
|
"""
|
||||||
|
تسک کوچک و اتمی: برای هر کاربر حداکثر یک ایمیل میفرستد (با لاگ ایدمپوتنسی).
|
||||||
|
"""
|
||||||
|
event = Event.objects.get(pk=event_id)
|
||||||
|
user = User.objects.get(pk=user_id)
|
||||||
|
|
||||||
|
# ساخت محتوا
|
||||||
|
context = {
|
||||||
|
"user": user,
|
||||||
|
"event": event,
|
||||||
|
"event_url": _event_url(event),
|
||||||
|
"start_time": fa_digits(jdate(event.start_time))
|
||||||
|
}
|
||||||
|
# ایدمپوتنسی: اگر قبلاً این ایمیل رزرو/ارسال شده، Skip
|
||||||
|
subject = f"دعوت به شرکت در «{event.title}»"
|
||||||
|
text_body = render_to_string("emails/event_invite_non_registered.txt", context)
|
||||||
|
html_body = render_to_string("emails/event_invite_non_registered.html", context)
|
||||||
|
context_key = _build_email_context(
|
||||||
|
"invite_non_registered",
|
||||||
|
event.slug or event.id,
|
||||||
|
html_body,
|
||||||
|
)
|
||||||
|
log, skip = EventEmailLog.claim(
|
||||||
|
event_id=event_id,
|
||||||
|
user_id=user_id,
|
||||||
|
kind=EventEmailLog.KIND_INVITE_NON_REGISTERED,
|
||||||
|
context=context_key,
|
||||||
|
)
|
||||||
|
if skip:
|
||||||
|
return {"skipped": True, "status": log.status}
|
||||||
|
|
||||||
|
try:
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_body,
|
||||||
|
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to=[user.email],
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html_body, "text/html")
|
||||||
|
msg.send()
|
||||||
|
|
||||||
|
log.mark_sent()
|
||||||
|
return f"Email sent to {user.email}"
|
||||||
|
except Exception as exc:
|
||||||
|
log.mark_failed(str(exc))
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(bind=True)
|
||||||
|
def queue_skyroom_credentials(self, event_id: int):
|
||||||
|
"""
|
||||||
|
تسک مادر: ثبتنامهای تاییدشده را پیدا میکند و برای هر Registration یک تسک کوچک میسازد.
|
||||||
|
"""
|
||||||
|
event = Event.objects.get(pk=event_id)
|
||||||
|
|
||||||
|
# فقط CONFIRMED ها + ایمیل معتبر
|
||||||
|
regs = (
|
||||||
|
_event_recipients(event, statuses=[Registration.StatusChoices.CONFIRMED])
|
||||||
|
.select_related("user", "event")
|
||||||
|
.exclude(user__email__isnull=True)
|
||||||
|
.exclude(user__email="")
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
reg_ids = list(regs.values_list("id", flat=True))
|
||||||
|
|
||||||
|
# ساخت group از تسکهای کوچک؛ هر کدوم فقط یک ایمیل ارسال میکنند
|
||||||
|
job = group(send_skyroom_credentials_to_user.s(event_id, rid) for rid in reg_ids)
|
||||||
|
|
||||||
|
# توصیه: اگر نتیجهها را لازم ندارید، در تنظیمات CELERY_TASK_IGNORE_RESULT=True بگذارید
|
||||||
|
res = job.apply_async()
|
||||||
|
logger.info(
|
||||||
|
'Queued %s Skyroom-credential emails for event "%s" (group_id=%s)',
|
||||||
|
len(reg_ids), event.title, res.id
|
||||||
|
)
|
||||||
|
return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id}
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task(
|
||||||
|
bind=True,
|
||||||
|
autoretry_for=(Exception,),
|
||||||
|
retry_backoff=True,
|
||||||
|
retry_jitter=True,
|
||||||
|
retry_kwargs={"max_retries": 3},
|
||||||
|
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
|
||||||
|
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
|
||||||
|
)
|
||||||
|
def send_skyroom_credentials_to_user(self, event_id: int, registration_id: int):
|
||||||
|
"""
|
||||||
|
تسک کوچک و اتمی: ارسال نامکاربری/رمز اسکایروم برای یک Registration.
|
||||||
|
با لاگ ایدمپوتنسی تا ارسال تکراری نداشته باشیم.
|
||||||
|
"""
|
||||||
|
user = None
|
||||||
|
log = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
|
||||||
|
user = r.user
|
||||||
|
event = r.event
|
||||||
|
|
||||||
|
# ساخت یوزرنیم/پسورد
|
||||||
|
sky_username = (user.email or "").strip().split("@")[0]
|
||||||
|
sky_password = str(r.ticket_id or "")[:8]
|
||||||
|
skyroom_url = event.online_link
|
||||||
|
|
||||||
|
context_key = _build_email_context(
|
||||||
|
"skyroom_credentials",
|
||||||
|
event.slug or event.id,
|
||||||
|
sky_username,
|
||||||
|
sky_password,
|
||||||
|
skyroom_url,
|
||||||
|
)
|
||||||
|
log, skip = EventEmailLog.claim(
|
||||||
|
event_id=event_id,
|
||||||
|
user_id=user.id,
|
||||||
|
kind=EventEmailLog.KIND_SKYROOM_CREDENTIALS,
|
||||||
|
context=context_key,
|
||||||
|
)
|
||||||
|
if skip:
|
||||||
|
return {"skipped": True, "status": log.status}
|
||||||
|
|
||||||
|
ctx = {
|
||||||
|
"user": user,
|
||||||
|
"event": event,
|
||||||
|
"skyroom_url": skyroom_url,
|
||||||
|
"sky_username": sky_username,
|
||||||
|
"sky_password": sky_password,
|
||||||
|
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
|
||||||
|
}
|
||||||
|
|
||||||
|
subject = f"اطلاعات دسترسی اسکایروم - {event.title}"
|
||||||
|
html = render_to_string("emails/skyroom_credentials.html", ctx)
|
||||||
|
text_body = strip_tags(html)
|
||||||
|
|
||||||
|
msg = EmailMultiAlternatives(
|
||||||
|
subject=subject,
|
||||||
|
body=text_body,
|
||||||
|
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||||
|
to=[user.email],
|
||||||
|
)
|
||||||
|
msg.attach_alternative(html, "text/html")
|
||||||
|
msg.send()
|
||||||
|
|
||||||
|
log.mark_sent()
|
||||||
|
|
||||||
|
logger.info('Skyroom credentials for "%s" sent to %s', event.title, user.email)
|
||||||
|
return f"Email sent to {user.email}"
|
||||||
|
|
||||||
|
except SoftTimeLimitExceeded as exc:
|
||||||
|
# ثبت خطا و اجازه به Celery برای retry خودکار
|
||||||
|
if log:
|
||||||
|
log.mark_failed("Soft time limit exceeded")
|
||||||
|
logger.warning(
|
||||||
|
"Soft time limit exceeded for event_id=%s, registration_id=%s", event_id, registration_id
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
if log:
|
||||||
|
log.mark_failed(str(exc))
|
||||||
|
logger.error("Failed to send skyroom credentials email: %s", exc, exc_info=True)
|
||||||
|
raise
|
||||||
89
backend/gallery/admin.py
Normal file
89
backend/gallery/admin.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from django.contrib import admin
|
||||||
|
from django.utils.html import format_html
|
||||||
|
|
||||||
|
from import_export.admin import ImportExportModelAdmin
|
||||||
|
|
||||||
|
from gallery.models import Gallery
|
||||||
|
from gallery.resources import GalleryResource
|
||||||
|
from utils.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||||
|
|
||||||
|
@admin.register(Gallery)
|
||||||
|
class GalleryAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||||
|
resource_class = GalleryResource
|
||||||
|
list_display = ('title', 'image_preview', 'uploaded_by', 'file_size_display', 'dimensions', 'is_public', 'created_at')
|
||||||
|
list_filter = ('is_public', 'created_at', SoftDeleteListFilter)
|
||||||
|
search_fields = ('title', 'description', 'alt_text')
|
||||||
|
readonly_fields = ('uploaded_by', 'file_size', 'width', 'height', 'image_preview_large', 'markdown_url')
|
||||||
|
|
||||||
|
fieldsets = (
|
||||||
|
('Image Info', {
|
||||||
|
'fields': ('title', 'description', 'image', 'alt_text', 'is_public')
|
||||||
|
}),
|
||||||
|
('Uploader', {
|
||||||
|
'fields': ('uploaded_by',),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Metadata', {
|
||||||
|
'fields': ('file_size', 'width', 'height'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Preview & Usage', {
|
||||||
|
'fields': ('image_preview_large', 'markdown_url'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
('Soft Delete', {
|
||||||
|
'fields': ('is_deleted', 'deleted_at'),
|
||||||
|
'classes': ('collapse',)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
actions = BaseModelAdmin.actions + ['make_public', 'make_private', 'restore_images']
|
||||||
|
|
||||||
|
def image_preview(self, obj):
|
||||||
|
if obj.image:
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" style="width: 50px; height: 50px; object-fit: cover; border-radius: 4px;" />',
|
||||||
|
obj.image.url
|
||||||
|
)
|
||||||
|
return "No Image"
|
||||||
|
image_preview.short_description = "Preview"
|
||||||
|
|
||||||
|
def image_preview_large(self, obj):
|
||||||
|
if obj.image:
|
||||||
|
return format_html(
|
||||||
|
'<img src="{}" style="max-width: 300px; max-height: 300px; object-fit: contain;" />',
|
||||||
|
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)
|
||||||
5
backend/gallery/apps.py
Normal file
5
backend/gallery/apps.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
class GalleryConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'gallery'
|
||||||
218
backend/gallery/fixtures/gallery.json
Normal file
218
backend/gallery/fixtures/gallery.json
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
36
backend/gallery/migrations/0001_initial.py
Normal file
36
backend/gallery/migrations/0001_initial.py
Normal file
@@ -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'],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
23
backend/gallery/migrations/0002_initial.py
Normal file
23
backend/gallery/migrations/0002_initial.py
Normal file
@@ -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),
|
||||||
|
),
|
||||||
|
]
|
||||||
0
backend/gallery/migrations/__init__.py
Normal file
0
backend/gallery/migrations/__init__.py
Normal file
82
backend/gallery/models.py
Normal file
82
backend/gallery/models.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
from django.db import models
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from utils.models import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
MAX_IMAGE_FILE_SIZE_BYTES = 2 * 1024 * 1024
|
||||||
|
|
||||||
|
class Gallery(BaseModel):
|
||||||
|
title = models.CharField(max_length=200)
|
||||||
|
description = models.TextField(blank=True)
|
||||||
|
image = models.ImageField(upload_to='gallery/')
|
||||||
|
uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='gallery_images')
|
||||||
|
alt_text = models.CharField(max_length=200, blank=True)
|
||||||
|
file_size = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
width = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
height = models.PositiveIntegerField(null=True, blank=True)
|
||||||
|
is_public = models.BooleanField(default=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
ordering = ['-created_at']
|
||||||
|
verbose_name_plural = "Gallery Images"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.title
|
||||||
|
|
||||||
|
def save(self, *args, **kwargs):
|
||||||
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
|
if self.image:
|
||||||
|
# Get file size
|
||||||
|
self.file_size = self.image.size
|
||||||
|
|
||||||
|
# Get image dimensions
|
||||||
|
with Image.open(self.image.path) as img:
|
||||||
|
self.width, self.height = img.size
|
||||||
|
|
||||||
|
# Compress image if it's too large
|
||||||
|
self.compress_image()
|
||||||
|
|
||||||
|
# Update fields without triggering save again
|
||||||
|
Gallery.objects.filter(pk=self.pk).update(
|
||||||
|
file_size=self.file_size,
|
||||||
|
width=self.width,
|
||||||
|
height=self.height
|
||||||
|
)
|
||||||
|
|
||||||
|
def compress_image(self):
|
||||||
|
"""Compress image if it's larger than 2MB or dimensions are too large"""
|
||||||
|
if not self.image:
|
||||||
|
return
|
||||||
|
|
||||||
|
with Image.open(self.image.path) as img:
|
||||||
|
# Convert to RGB if necessary
|
||||||
|
if img.mode in ("RGBA", "P"):
|
||||||
|
img = img.convert("RGB")
|
||||||
|
|
||||||
|
# Resize if too large
|
||||||
|
max_size = (1920, 1080)
|
||||||
|
if img.size[0] > max_size[0] or img.size[1] > max_size[1]:
|
||||||
|
img.thumbnail(max_size, Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
|
# Compress if file size is too large
|
||||||
|
quality = 85
|
||||||
|
if self.file_size and self.file_size > MAX_IMAGE_FILE_SIZE_BYTES:
|
||||||
|
quality = 70
|
||||||
|
|
||||||
|
img.save(self.image.path, "JPEG", quality=quality, optimize=True)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def file_size_mb(self):
|
||||||
|
"""Return file size in MB"""
|
||||||
|
if self.file_size:
|
||||||
|
return round(self.file_size / (1024 * 1024), 2)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def markdown_url(self):
|
||||||
|
"""Return URL for use in markdown"""
|
||||||
|
return f""
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user