├── .env.dist ├── .flake8 ├── .github └── workflows │ └── django.yml ├── .gitignore ├── Makefile ├── Procfile ├── Procfile.dev ├── README.md ├── affiliate ├── __init__.py ├── apps.py ├── dao.py ├── filters.py ├── migrations │ └── __init__.py ├── models.py ├── serializers.py ├── sql │ ├── daily_report.sql │ ├── goal_report.sql │ ├── offer_report.sql │ └── sub_report.sql ├── tests │ ├── __init__.py │ ├── test_conversion.py │ ├── test_offer.py │ └── test_stats.py ├── urls.py └── views │ ├── __init__.py │ ├── conversions.py │ ├── offers.py │ ├── profile.py │ ├── register.py │ └── stats.py ├── api ├── __init__.py ├── permissions.py ├── tests │ ├── __init__.py │ ├── test_advertiser.py │ ├── test_conversion.py │ ├── test_landing.py │ ├── test_offer.py │ ├── test_offer_traffic_source.py │ └── test_payout.py ├── urls.py └── views │ ├── __init__.py │ ├── advertiser.py │ ├── conversions.py │ ├── landing.py │ ├── offer.py │ ├── offer_traffic_source.py │ └── payout.py ├── app.json ├── dictionaries ├── __init__.py ├── apps.py ├── urls.py └── views │ ├── __init__.py │ ├── categories.py │ └── countries.py ├── docker-compose.yml ├── docker └── Dockerfile ├── docs ├── api-spec.png ├── index.html └── schema.yml ├── ext ├── ipapi │ ├── __init__.py │ └── api.py └── ipstack │ ├── __init__.py │ └── api.py ├── manage.py ├── mypy.ini ├── network ├── __init__.py ├── apps.py ├── dao.py ├── migrations │ └── __init__.py ├── sql │ ├── affiliate_report.sql │ ├── daily_report.sql │ └── offer_report.sql ├── tests │ ├── __init__.py │ ├── test_affiliate.py │ ├── test_conversion.py │ ├── test_offer.py │ └── test_stats.py ├── urls.py └── views │ ├── __init__.py │ ├── affiliates.py │ ├── conversions.py │ ├── offers.py │ └── stats.py ├── offer ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_offer_countries.py │ ├── 0003_offer_description.py │ ├── 0004_auto_20191009_1712.py │ ├── 0005_category.py │ ├── 0006_trafficsource.py │ ├── 0007_offer_categories.py │ ├── 0008_auto_20200618_1647.py │ ├── 0009_auto_20200618_1706.py │ ├── 0010_currency.py │ ├── 0011_payout.py │ ├── 0012_auto_20200618_1922.py │ ├── 0013_offer_preview_link.py │ ├── 0014_advertiser.py │ ├── 0015_offer_advertiser.py │ ├── 0016_auto_20200629_2215.py │ ├── 0017_offer_icon.py │ ├── 0018_auto_20200726_1815.py │ ├── 0019_offer_description_html.py │ ├── 0020_landing.py │ ├── 0021_auto_20200903_2149.py │ └── __init__.py ├── models.py └── tasks │ ├── __init__.py │ └── cache_offers.py ├── permissions.txt ├── postback ├── __init__.py ├── admin.py ├── apps.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_log.py │ ├── 0003_auto_20200827_1244.py │ ├── 0004_auto_20201018_0101.py │ └── __init__.py ├── models.py ├── tasks │ ├── __init__.py │ └── send_postback.py └── tests │ ├── __init__.py │ └── test_send_postback.py ├── project ├── __init__.py ├── _celery.py ├── redis_conn.py ├── settings │ ├── __init__.py │ ├── base.py │ ├── local.dist.py │ └── prod.py ├── urls.py └── wsgi.py ├── pytest.ini ├── requirements.dev.txt ├── requirements.txt ├── tracker ├── __init__.py ├── admin.py ├── apps.py ├── dao.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_auto_20200618_1524.py │ ├── 0003_auto_20200629_2057.py │ ├── 0004_conversion_goal.py │ ├── 0005_conversion_currency.py │ ├── 0006_conversion_comment.py │ ├── 0007_auto_20200803_1648.py │ ├── 0008_auto_20200803_1822.py │ ├── 0009_auto_20200803_1824.py │ ├── 0010_auto_20200803_1825.py │ ├── 0011_auto_20200826_2206.py │ ├── 0012_auto_20200914_1037.py │ ├── 0013_auto_20201018_0101.py │ └── __init__.py ├── models.py ├── signals.py ├── tasks │ ├── __init__.py │ ├── click.py │ ├── conversion.py │ └── sync.py ├── tests │ ├── __init__.py │ ├── test_click_task.py │ ├── test_click_view.py │ ├── test_conversion_task.py │ ├── test_find_payout.py │ └── test_postback_view.py ├── urls.py └── views.py └── user_profile ├── __init__.py ├── admin.py ├── apps.py ├── migrations ├── 0001_initial.py ├── 0002_profile_manager.py ├── 0003_auto_20200617_2157.py └── __init__.py ├── models.py ├── signals.py └── tests ├── __init__.py ├── test_profile.py └── test_signal.py /.env.dist: -------------------------------------------------------------------------------- 1 | REDIS_URL=redis://redis 2 | TRACKER_URL=http://0.0.0.0:8000 3 | IPSTACK_TOKEN=xxx -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | filename = *.py 3 | exclude = venv,.git,__pycache,migrations 4 | max-line-length = 79 5 | max-complexity = 10 -------------------------------------------------------------------------------- /.github/workflows/django.yml: -------------------------------------------------------------------------------- 1 | name: Django CI 2 | 3 | on: 4 | push: 5 | branches: [ "*" ] 6 | pull_request: 7 | branches: [ "*" ] 8 | 9 | jobs: 10 | build: 11 | 12 | runs-on: ubuntu-22.04 13 | strategy: 14 | max-parallel: 4 15 | matrix: 16 | db: [postgres] 17 | python-version: ["3.10"] 18 | 19 | 20 | services: 21 | 22 | postgres: 23 | image: postgres:10 24 | env: 25 | POSTGRES_DB: postgres 26 | POSTGRES_USER: postgres 27 | POSTGRES_PASSWORD: postgres 28 | options: >- 29 | --health-cmd pg_isready 30 | --health-interval 10s 31 | --health-timeout 5s 32 | --health-retries 5 33 | ports: 34 | - 5432:5432 35 | 36 | redis: 37 | # Docker Hub image 38 | image: redis 39 | # Set health checks to wait until redis has started 40 | options: >- 41 | --health-cmd "redis-cli ping" 42 | --health-interval 10s 43 | --health-timeout 5s 44 | --health-retries 5 45 | ports: 46 | - 6379:6379 47 | 48 | 49 | steps: 50 | - uses: actions/checkout@v3 51 | - name: Set up Python ${{ matrix.python-version }} 52 | uses: actions/setup-python@v3 53 | with: 54 | python-version: ${{ matrix.python-version }} 55 | - name: Install Dependencies 56 | run: | 57 | python -m pip install --upgrade pip 58 | pip install -r requirements.txt 59 | - name: Flake8 60 | run: flake8 . 61 | - name: Run Tests 62 | run: python manage.py test 63 | env: 64 | DJ_SECRET_KEY: xxx 65 | DATABASE_URL: postgresql://postgres:postgres@127.0.0.1/postgres 66 | REDIS_URL: redis://127.0.0.1 67 | TRACKER_URL: https://example.com 68 | IPSTACK_TOKEN: xxx 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | develop-eggs/ 12 | dist/ 13 | downloads/ 14 | eggs/ 15 | .eggs/ 16 | lib/ 17 | lib64/ 18 | parts/ 19 | sdist/ 20 | var/ 21 | wheels/ 22 | *.egg-info/ 23 | .installed.cfg 24 | *.egg 25 | 26 | # PyInstaller 27 | # Usually these files are written by a python script from a template 28 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 29 | *.manifest 30 | *.spec 31 | 32 | # Installer logs 33 | pip-log.txt 34 | pip-delete-this-directory.txt 35 | 36 | # Unit test / coverage reports 37 | htmlcov/ 38 | .tox/ 39 | .coverage 40 | .coverage.* 41 | .cache 42 | nosetests.xml 43 | coverage.xml 44 | *.cover 45 | .hypothesis/ 46 | .pytest_cache 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | celerybeat.pid 78 | 79 | # SageMath parsed files 80 | *.sage.py 81 | 82 | # Environments 83 | .env 84 | .venv 85 | env/ 86 | venv/ 87 | ENV/ 88 | 89 | # Spyder project settings 90 | .spyderproject 91 | .spyproject 92 | 93 | # Rope project settings 94 | .ropeproject 95 | 96 | # mkdocs documentation 97 | /site 98 | 99 | # mypy 100 | .mypy_cache/ 101 | 102 | #IDE 103 | .vscode 104 | .idea/ 105 | 106 | # Docker Compose 107 | volumes 108 | 109 | # Project settings 110 | /project/settings/local.py 111 | 112 | sandbox 113 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: makemigrations 2 | makemigrations: 3 | docker-compose run --rm web python manage.py makemigrations 4 | 5 | 6 | .PHONY: migrate 7 | migrate: 8 | docker-compose run --rm web python manage.py migrate 9 | 10 | 11 | .PHONY: dshell 12 | dshell: 13 | docker-compose run --rm web python manage.py shell 14 | 15 | 16 | .PHONY: flake 17 | flake: 18 | docker-compose run --rm web flake8 . | grep -v migrations | grep -v 'dao.py' 19 | 20 | 21 | .PHONY: pytest 22 | pytest: 23 | docker-compose run --rm web pytest 24 | 25 | 26 | .PHONY: test 27 | test: 28 | docker-compose run --rm web python manage.py test 29 | 30 | 31 | .PHONY: mypy 32 | mypy: 33 | docker-compose run --rm web mypy . 34 | 35 | 36 | .PHONY: psql 37 | psql: 38 | docker-compose run --rm postgres psql --host=postgres --dbname=postgres --username=postgres 39 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: gunicorn project.wsgi 2 | worker: celery -A project._celery:_celery worker -l INFO 3 | beat: celery -A project._celery:_celery beat -l INFO -------------------------------------------------------------------------------- /Procfile.dev: -------------------------------------------------------------------------------- 1 | web: python manage.py runserver 0.0.0.0:80 2 | worker: celery -A project._celery:_celery worker -l INFO 3 | beat: celery -A project._celery:_celery beat -l INFO -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![telegram_badge]][telegram_link] 2 | 3 |
4 |

5 | Report Bug 6 | · 7 | Request Feature 8 |

9 |
10 | 11 | # About 12 | 13 | [![](docs/api-spec.png)](https://cpanova.github.io/cpa-network/index.html) 14 | 15 | Backend for CPA Network 16 | 17 | Keywords: Affiliate Tracking Software, CPA Platform, Affiliate Management Platform 18 | 19 | # Features 20 | 21 | - Affiliate Registration 22 | - Offer Management 23 | - Conversion Import 24 | - Reports 25 | 26 | 27 | 28 | 29 | [telegram_badge]: https://img.shields.io/badge/telegram-252850?style=plastic&logo=telegram 30 | [telegram_link]: https://t.me/bloogrox 31 | 32 | # Documentation 33 | 34 | [API Documentation](https://cpanova.github.io/cpa-network/index.html) 35 | 36 | # Development 37 | 38 | - Copy `project/settings/local.dist.py` to `project/settings/local.py` 39 | - Copy `.env.dist` to `.env` 40 | 41 | ``` 42 | docker compose up 43 | ``` 44 | 45 | django admin: http://0.0.0.0:8000/admin/login/ 46 | 47 | API spec: http://0.0.0.0:8000/api/ 48 | 49 | # Contribute 50 | 51 | Take part in short [survey](https://forms.gle/fCU6NYjuY8A1J8xd6) (5 questions, no email) -------------------------------------------------------------------------------- /affiliate/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/affiliate/__init__.py -------------------------------------------------------------------------------- /affiliate/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AffiliateConfig(AppConfig): 5 | name = 'affiliate' 6 | 7 | # def ready(self): 8 | # super(AffiliateConfig, self).ready() 9 | # import affiliate.signals # noqa: F401 10 | -------------------------------------------------------------------------------- /affiliate/dao.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import datetime 3 | from typing import List, Dict, Any 4 | from django.db import connection 5 | 6 | 7 | def daily_report( 8 | user_id: int, start_date: datetime, end_date: datetime, 9 | offer_id: int = 0) -> list: 10 | 11 | offer_filter_clause = "" 12 | 13 | if offer_id: 14 | offer_filter_clause = f"AND offer_id = {offer_id}" 15 | 16 | filepath = os.path.join( 17 | os.path.dirname(__file__), 'sql', 'daily_report.sql' 18 | ) 19 | with open(filepath, 'r') as f: 20 | sql = f.read().format(**locals()) 21 | 22 | colnames = [ 23 | 'date', 24 | 25 | 'clicks', 26 | 27 | 'total_qty', 28 | 'approved_qty', 29 | 'hold_qty', 30 | 'rejected_qty', 31 | 32 | 'cr', 33 | 34 | 'total_payout', 35 | 'approved_payout', 36 | 'hold_payout', 37 | 'rejected_payout' 38 | ] 39 | 40 | with connection.cursor() as cursor: 41 | cursor.execute(sql) 42 | data = cursor.fetchall() 43 | data = [dict(zip(colnames, row)) for row in data] 44 | 45 | return data 46 | 47 | 48 | def offer_report( 49 | user_id: int, start_date: datetime, 50 | end_date: datetime) -> List[Dict[str, Any]]: 51 | 52 | colnames = [ 53 | 'offer_id', 54 | 'offer_title', 55 | 56 | 'clicks', 57 | 58 | 'total_qty', 59 | 'approved_qty', 60 | 'hold_qty', 61 | 'rejected_qty', 62 | 63 | 'cr', 64 | 65 | 'total_payout', 66 | 'approved_payout', 67 | 'hold_payout', 68 | 'rejected_payout' 69 | ] 70 | 71 | filepath = os.path.join( 72 | os.path.dirname(__file__), 'sql', 'offer_report.sql' 73 | ) 74 | with open(filepath, 'r') as f: 75 | sql = f.read().format(**locals()) 76 | 77 | with connection.cursor() as cursor: 78 | cursor.execute(sql) 79 | data = cursor.fetchall() 80 | data = [dict(zip(colnames, row)) for row in data] 81 | 82 | return data 83 | 84 | 85 | def goal_report( 86 | user_id: int, start_date: datetime, end_date: datetime) -> list: 87 | 88 | colnames = [ 89 | 'goal_id', 90 | 'goal_name', 91 | 92 | 'total_qty', 93 | 'approved_qty', 94 | 'hold_qty', 95 | 'rejected_qty', 96 | 97 | 'total_payout', 98 | 'approved_payout', 99 | 'hold_payout', 100 | 'rejected_payout' 101 | ] 102 | 103 | filepath = os.path.join( 104 | os.path.dirname(__file__), 'sql', 'goal_report.sql' 105 | ) 106 | with open(filepath, 'r') as f: 107 | sql = f.read().format(**locals()) 108 | 109 | with connection.cursor() as cursor: 110 | cursor.execute(sql) 111 | data = cursor.fetchall() 112 | data = [dict(zip(colnames, row)) for row in data] 113 | 114 | return data 115 | 116 | 117 | def report_bysub( 118 | sub_index: int, offer_id: int, user_id: int, 119 | start_date: datetime, end_date: datetime) -> list: 120 | 121 | colnames = [ 122 | 'sub', 123 | 124 | 'clicks', 125 | 126 | 'total_qty', 127 | 'approved_qty', 128 | 'hold_qty', 129 | 'rejected_qty', 130 | 131 | 'cr', 132 | 133 | 'total_payout', 134 | 'approved_payout', 135 | 'hold_payout', 136 | 'rejected_payout' 137 | ] 138 | 139 | offer_filter_clause = f" AND offer_id = {offer_id} " 140 | 141 | filepath = os.path.join( 142 | os.path.dirname(__file__), 'sql', 'sub_report.sql' 143 | ) 144 | with open(filepath, 'r') as f: 145 | sql = f.read().format(**locals()) 146 | 147 | with connection.cursor() as cursor: 148 | cursor.execute(sql) 149 | data = cursor.fetchall() 150 | data = [dict(zip(colnames, row)) for row in data] 151 | 152 | return data 153 | -------------------------------------------------------------------------------- /affiliate/filters.py: -------------------------------------------------------------------------------- 1 | from django_filters import Filter 2 | from django_filters.constants import EMPTY_VALUES 3 | 4 | 5 | class CommaSeparatedTextFilter(Filter): 6 | """Custom filter allow set multiple values to field filter. 7 | 8 | Note: it use case-sensitive search when choosing from list, 9 | you should use pre-annotate to lowercase and lookup __in in annotation 10 | """ 11 | 12 | def filter(self, qs, value): 13 | 14 | # if field filled, - cplit by 15 | if value not in EMPTY_VALUES: 16 | value = value.split(',') 17 | 18 | # set lookup expression to choose in list or to default iexact 19 | # it'll be used in superclass 20 | self.lookup_expr = isinstance(value, list) and 'in' or 'iexact' 21 | 22 | return super(CommaSeparatedTextFilter, self).filter(qs, value) 23 | -------------------------------------------------------------------------------- /affiliate/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/affiliate/migrations/__init__.py -------------------------------------------------------------------------------- /affiliate/models.py: -------------------------------------------------------------------------------- 1 | # from django.contrib.auth import get_user_model 2 | # from django.db import models 3 | 4 | 5 | # class Affiliate(models.Model): 6 | # user = models.OneToOneField(get_user_model(), on_delete=models.CASCADE) 7 | 8 | # def __str__(self): 9 | # return self.user.username 10 | -------------------------------------------------------------------------------- /affiliate/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from django.contrib.auth import get_user_model 3 | from rest_framework.exceptions import ValidationError 4 | 5 | 6 | class UserSerializer(serializers.ModelSerializer): 7 | password = serializers.CharField(write_only=True) 8 | confirm_password = serializers.CharField(write_only=True) 9 | 10 | def create(self, validated_data): 11 | if validated_data["password"] != validated_data["confirm_password"]: 12 | raise ValidationError( 13 | {'confirm_password': ['Wrong confirm password']} 14 | ) 15 | 16 | user = get_user_model().objects.create( 17 | username=validated_data['username'] 18 | ) 19 | user.set_password(validated_data['password']) 20 | user.is_staff = False 21 | user.is_active = True 22 | user.save() 23 | 24 | return user 25 | 26 | class Meta: 27 | model = get_user_model() 28 | fields = ('id', 'username', 'password', 'confirm_password',) 29 | write_only_fields = ('password',) 30 | read_only_fields = ('id',) 31 | -------------------------------------------------------------------------------- /affiliate/sql/daily_report.sql: -------------------------------------------------------------------------------- 1 | (SELECT 2 | COALESCE(cl.day, cv.day), 3 | COALESCE(cl.clicks, 0), 4 | COALESCE(cv.total_qty, 0), 5 | COALESCE(cv.approved_qty, 0), 6 | COALESCE(cv.hold_qty, 0), 7 | COALESCE(cv.rejected_qty, 0), 8 | COALESCE( 9 | case cl.clicks 10 | when 0 then 0 -- avoid divizion by zero 11 | else (100 * cv.total_qty / cl.clicks) 12 | end 13 | , 0) AS cr, 14 | COALESCE(cv.total_payout, 0), 15 | COALESCE(cv.approved_payout, 0), 16 | COALESCE(cv.hold_payout, 0), 17 | COALESCE(cv.rejected_payout, 0) 18 | FROM 19 | ( 20 | SELECT 21 | created_at::date AS day, 22 | count(*) AS clicks 23 | FROM tracker_click 24 | WHERE 25 | affiliate_id = {user_id} 26 | AND created_at between '{start_date}' AND '{end_date}' 27 | {offer_filter_clause} 28 | GROUP BY day 29 | ) AS cl 30 | FULL OUTER JOIN 31 | ( 32 | SELECT 33 | created_at::date AS day, 34 | count(*) AS total_qty, 35 | count(*) FILTER (WHERE status = 'approved') AS approved_qty, 36 | count(*) FILTER (WHERE status = 'hold') AS hold_qty, 37 | count(*) FILTER (WHERE status = 'rejected') AS rejected_qty, 38 | sum(payout) AS total_payout, 39 | sum(payout) FILTER (WHERE status = 'approved') AS approved_payout, 40 | sum(payout) FILTER (WHERE status = 'hold') AS hold_payout, 41 | sum(payout) FILTER (WHERE status = 'rejected') AS rejected_payout 42 | FROM tracker_conversion 43 | WHERE 44 | affiliate_id = {user_id} 45 | AND created_at between '{start_date}' AND '{end_date}' 46 | {offer_filter_clause} 47 | GROUP BY day 48 | ) AS cv 49 | ON cl.day = cv.day 50 | ORDER BY cl.day DESC) 51 | ; -------------------------------------------------------------------------------- /affiliate/sql/goal_report.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | report.goal_id, 3 | goal.name, 4 | COALESCE(report.total_qty, 0), 5 | COALESCE(report.approved_qty, 0), 6 | COALESCE(report.hold_qty, 0), 7 | COALESCE(report.rejected_qty, 0), 8 | COALESCE(report.total_payout, 0), 9 | COALESCE(report.approved_payout, 0), 10 | COALESCE(report.hold_payout, 0), 11 | COALESCE(report.rejected_payout, 0) 12 | FROM ( 13 | SELECT 14 | goal_id, 15 | count(*) AS total_qty, 16 | count(*) FILTER (WHERE status = 'approved') AS approved_qty, 17 | count(*) FILTER (WHERE status = 'hold') AS hold_qty, 18 | count(*) FILTER (WHERE status = 'rejected') AS rejected_qty, 19 | sum(payout) AS total_payout, 20 | sum(payout) FILTER (WHERE status = 'approved') AS approved_payout, 21 | sum(payout) FILTER (WHERE status = 'hold') AS hold_payout, 22 | sum(payout) FILTER (WHERE status = 'rejected') AS rejected_payout 23 | FROM tracker_conversion 24 | WHERE 25 | affiliate_id = {user_id} 26 | AND created_at between '{start_date}' AND '{end_date}' 27 | GROUP BY goal_id 28 | ) AS report 29 | LEFT JOIN offer_goal AS goal 30 | ON report.goal_id = goal.id 31 | ; -------------------------------------------------------------------------------- /affiliate/sql/offer_report.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | report.offer_id, 3 | o.title, 4 | COALESCE(report.clicks, 0), 5 | COALESCE(report.total_qty, 0), 6 | COALESCE(report.approved_qty, 0), 7 | COALESCE(report.hold_qty, 0), 8 | COALESCE(report.rejected_qty, 0), 9 | COALESCE(report.cr, 0), 10 | COALESCE(report.total_payout, 0), 11 | COALESCE(report.approved_payout, 0), 12 | COALESCE(report.hold_payout, 0), 13 | COALESCE(report.rejected_payout, 0) 14 | FROM 15 | ( 16 | SELECT 17 | COALESCE(cl.offer_id, cv.offer_id) as offer_id, 18 | cl.clicks, 19 | cv.total_qty, 20 | cv.approved_qty, 21 | cv.hold_qty, 22 | cv.rejected_qty, 23 | case cl.clicks 24 | when 0 then 0 -- avoid divizion by zero 25 | else (100 * cv.total_qty / cl.clicks) 26 | end AS cr, 27 | cv.total_payout, 28 | cv.approved_payout, 29 | cv.hold_payout, 30 | cv.rejected_payout 31 | FROM 32 | ( 33 | SELECT 34 | offer_id, 35 | count(*) as clicks 36 | FROM tracker_click 37 | WHERE 38 | affiliate_id = {user_id} 39 | AND created_at between '{start_date}' AND '{end_date}' 40 | GROUP BY offer_id 41 | ) AS cl 42 | FULL OUTER JOIN 43 | ( 44 | SELECT 45 | offer_id, 46 | count(*) AS total_qty, 47 | count(*) FILTER (WHERE status = 'approved') AS approved_qty, 48 | count(*) FILTER (WHERE status = 'hold') AS hold_qty, 49 | count(*) FILTER (WHERE status = 'rejected') AS rejected_qty, 50 | sum(payout) AS total_payout, 51 | sum(payout) FILTER (WHERE status = 'approved') AS approved_payout, 52 | sum(payout) FILTER (WHERE status = 'hold') AS hold_payout, 53 | sum(payout) FILTER (WHERE status = 'rejected') AS rejected_payout 54 | FROM tracker_conversion 55 | WHERE 56 | affiliate_id = {user_id} 57 | AND created_at between '{start_date}' AND '{end_date}' 58 | GROUP BY offer_id 59 | ) AS cv 60 | ON cl.offer_id = cv.offer_id 61 | ) AS report 62 | LEFT JOIN offer_offer AS o 63 | ON report.offer_id = o.id 64 | ; -------------------------------------------------------------------------------- /affiliate/sql/sub_report.sql: -------------------------------------------------------------------------------- 1 | (SELECT 2 | COALESCE(cl.sub, cv.sub), 3 | COALESCE(cl.clicks, 0), 4 | COALESCE(cv.total_qty, 0), 5 | COALESCE(cv.approved_qty, 0), 6 | COALESCE(cv.hold_qty, 0), 7 | COALESCE(cv.rejected_qty, 0), 8 | COALESCE( 9 | case cl.clicks 10 | when 0 then 0 -- avoid divizion by zero 11 | else (100 * cv.total_qty / cl.clicks) 12 | end 13 | , 0) AS cr, 14 | COALESCE(cv.total_payout, 0), 15 | COALESCE(cv.approved_payout, 0), 16 | COALESCE(cv.hold_payout, 0), 17 | COALESCE(cv.rejected_payout, 0) 18 | FROM 19 | ( 20 | SELECT 21 | sub{sub_index} AS sub, 22 | count(*) AS clicks 23 | FROM tracker_click 24 | WHERE 25 | affiliate_id = {user_id} 26 | AND created_at between '{start_date}' AND '{end_date}' 27 | {offer_filter_clause} 28 | GROUP BY sub 29 | ) AS cl 30 | FULL OUTER JOIN 31 | ( 32 | SELECT 33 | sub{sub_index} AS sub, 34 | count(*) AS total_qty, 35 | count(*) FILTER (WHERE status = 'approved') AS approved_qty, 36 | count(*) FILTER (WHERE status = 'hold') AS hold_qty, 37 | count(*) FILTER (WHERE status = 'rejected') AS rejected_qty, 38 | sum(payout) AS total_payout, 39 | sum(payout) FILTER (WHERE status = 'approved') AS approved_payout, 40 | sum(payout) FILTER (WHERE status = 'hold') AS hold_payout, 41 | sum(payout) FILTER (WHERE status = 'rejected') AS rejected_payout 42 | FROM tracker_conversion 43 | WHERE 44 | affiliate_id = {user_id} 45 | AND created_at between '{start_date}' AND '{end_date}' 46 | {offer_filter_clause} 47 | GROUP BY sub 48 | ) AS cv 49 | ON cl.sub = cv.sub 50 | ORDER BY cl.sub ASC) 51 | ; -------------------------------------------------------------------------------- /affiliate/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/affiliate/tests/__init__.py -------------------------------------------------------------------------------- /affiliate/tests/test_conversion.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import pytz 3 | import datetime 4 | from rest_framework.test import APITestCase 5 | from django.contrib.auth import get_user_model 6 | from offer.models import Offer 7 | from tracker.models import Conversion 8 | 9 | 10 | class ConversionTestCase(APITestCase): 11 | list_url = '/affiliate/conversions/' 12 | 13 | def setUp(self): 14 | super(ConversionTestCase, self).setUp() 15 | credentials = { 16 | 'username': 'test@test.com', 17 | 'password': '1234', 18 | } 19 | self.user = get_user_model().objects.create_user(**credentials) 20 | self.client.login(**credentials) 21 | self.offer = Offer.objects.create( 22 | title='blabla', 23 | description='blabla' 24 | ) 25 | self.conversions = [ 26 | Conversion.objects.create( 27 | click_id=uuid.uuid4(), 28 | click_date=datetime.datetime.now(pytz.UTC), 29 | revenue=0.0, 30 | payout=0.0, 31 | ip="1.1.1.1", 32 | affiliate_id=self.user.id, 33 | offer_id=self.offer.id 34 | # id=x, 35 | # title='blabla', 36 | # description='blabla' 37 | ) for x in range(3) 38 | ] 39 | 40 | def test_list(self): 41 | response = self.client.get(self.list_url) 42 | self.assertEqual(200, response.status_code) 43 | self.assertIn('offer', response.data[0]) 44 | self.assertEqual(self.offer.id, response.data[0]['offer']['id']) 45 | # self.assertIn('description', response.data[0]) 46 | # self.assertIn('stop_at', response.data[0]) 47 | -------------------------------------------------------------------------------- /affiliate/tests/test_offer.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import APITestCase 2 | from django.contrib.auth import get_user_model 3 | from offer.models import Offer 4 | 5 | 6 | class OfferTestCase(APITestCase): 7 | list_url = '/affiliate/offers/' 8 | retrieve_url = '/affiliate/offers/1/' 9 | tracking_link_url = '/affiliate/offers/1/tracking-link/' 10 | 11 | def setUp(self): 12 | super(OfferTestCase, self).setUp() 13 | credentials = { 14 | 'username': 'test@test.com', 15 | 'password': '1234', 16 | } 17 | self.user = get_user_model().objects.create_user(**credentials) 18 | self.client.login(**credentials) 19 | self.offers = [ 20 | Offer.objects.create( 21 | id=x, 22 | title='blabla', 23 | description='blabla' 24 | ) for x in range(3) 25 | ] 26 | 27 | def test_list(self): 28 | response = self.client.get(self.list_url) 29 | self.assertEqual(200, response.status_code) 30 | self.assertIn('title', response.data[0]) 31 | self.assertIn('description', response.data[0]) 32 | # self.assertIn('stop_at', response.data[0]) 33 | 34 | def test_retrieve(self): 35 | response = self.client.get(self.retrieve_url) 36 | self.assertEqual(200, response.status_code) 37 | self.assertIn('title', response.data) 38 | self.assertIn('description', response.data) 39 | self.assertIn('preview_link', response.data) 40 | self.assertIn('countries', response.data) 41 | self.assertIn('categories', response.data) 42 | self.assertIn('traffic_sources', response.data) 43 | self.assertIn('payouts', response.data) 44 | 45 | def test_tracking_link(self): 46 | response = self.client.get(self.tracking_link_url) 47 | self.assertEqual(200, response.status_code) 48 | self.assertIn('url', response.data) 49 | -------------------------------------------------------------------------------- /affiliate/tests/test_stats.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import pytz 3 | import datetime 4 | from rest_framework.test import APITestCase 5 | from django.contrib.auth import get_user_model 6 | from offer.models import Offer 7 | from tracker.models import Click, Conversion 8 | 9 | 10 | class StatsTestCase(APITestCase): 11 | offers_stats_url = '/affiliate/stats/offers/' 12 | goal_stats_url = '/affiliate/stats/by-goal/' 13 | daily_stats_url = '/affiliate/stats/daily/' 14 | sub_stats_url = '/affiliate/stats/by-sub/1/?offer_id=1' 15 | 16 | def setUp(self): 17 | super(StatsTestCase, self).setUp() 18 | credentials = { 19 | 'username': 'test@test.com', 20 | 'password': '1234', 21 | } 22 | self.user = get_user_model().objects.create_user(**credentials) 23 | self.client.login(**credentials) 24 | self.offer = Offer.objects.create( 25 | id=1, 26 | title='blabla', 27 | description='blabla' 28 | ) 29 | sample_values = { 30 | 'affiliate_id': self.user.id, 31 | 'offer_id': self.offer.id, 32 | 'ip': '1.1.1.1', 33 | 'revenue': 0.0, 34 | 'payout': 0.0 35 | } 36 | for _ in range(100): 37 | Click.objects.create(**sample_values) 38 | for _ in range(3): 39 | Conversion.objects.create( 40 | click_id=uuid.uuid4(), 41 | click_date=datetime.datetime.now(pytz.UTC), 42 | **sample_values 43 | ) 44 | 45 | def test_offers_stats(self): 46 | response = self.client.get(self.offers_stats_url) 47 | self.assertEqual(200, response.status_code) 48 | self.assertTrue(bool(len(response.data))) 49 | self.assertIn('offer_id', response.data[0]) 50 | self.assertIn('offer_title', response.data[0]) 51 | self.assertIn('clicks', response.data[0]) 52 | self.assertTrue(response.data[0]['clicks'] == 100) 53 | self.assertIn('total_qty', response.data[0]) 54 | self.assertIn('approved_qty', response.data[0]) 55 | self.assertIn('hold_qty', response.data[0]) 56 | self.assertIn('rejected_qty', response.data[0]) 57 | self.assertIn('cr', response.data[0]) 58 | self.assertIn('approved_payout', response.data[0]) 59 | self.assertIn('hold_payout', response.data[0]) 60 | self.assertIn('rejected_payout', response.data[0]) 61 | self.assertIn('total_payout', response.data[0]) 62 | 63 | def test_daily_stats(self): 64 | response = self.client.get(self.daily_stats_url) 65 | self.assertEqual(200, response.status_code) 66 | self.assertTrue(bool(len(response.data))) 67 | self.assertIn('date', response.data[0]) 68 | self.assertIn('clicks', response.data[0]) 69 | self.assertTrue(response.data[0]['clicks'] == 100) 70 | self.assertIn('total_qty', response.data[0]) 71 | self.assertIn('approved_qty', response.data[0]) 72 | self.assertIn('hold_qty', response.data[0]) 73 | self.assertIn('rejected_qty', response.data[0]) 74 | self.assertIn('cr', response.data[0]) 75 | self.assertIn('approved_payout', response.data[0]) 76 | self.assertIn('hold_payout', response.data[0]) 77 | self.assertIn('rejected_payout', response.data[0]) 78 | self.assertIn('total_payout', response.data[0]) 79 | 80 | def test_goal_stats(self): 81 | response = self.client.get(self.goal_stats_url) 82 | self.assertEqual(200, response.status_code) 83 | self.assertIn('goal_id', response.data[0]) 84 | self.assertIn('goal_name', response.data[0]) 85 | self.assertIn('total_qty', response.data[0]) 86 | self.assertIn('approved_qty', response.data[0]) 87 | self.assertIn('hold_qty', response.data[0]) 88 | self.assertIn('rejected_qty', response.data[0]) 89 | self.assertIn('approved_payout', response.data[0]) 90 | self.assertIn('hold_payout', response.data[0]) 91 | self.assertIn('rejected_payout', response.data[0]) 92 | self.assertIn('total_payout', response.data[0]) 93 | 94 | def test_sub_stats(self): 95 | response = self.client.get(self.sub_stats_url) 96 | self.assertEqual(200, response.status_code) 97 | self.assertIn('sub', response.data[0]) 98 | self.assertIn('clicks', response.data[0]) 99 | self.assertIn('total_qty', response.data[0]) 100 | self.assertIn('approved_qty', response.data[0]) 101 | self.assertIn('hold_qty', response.data[0]) 102 | self.assertIn('rejected_qty', response.data[0]) 103 | self.assertIn('cr', response.data[0]) 104 | self.assertIn('approved_payout', response.data[0]) 105 | self.assertIn('hold_payout', response.data[0]) 106 | self.assertIn('rejected_payout', response.data[0]) 107 | self.assertIn('total_payout', response.data[0]) 108 | -------------------------------------------------------------------------------- /affiliate/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from .views.profile import AffiliateRetrieveAPIView 4 | from .views.register import CreateUserView 5 | from .views.offers import OfferListView, OfferRetrieveView, TrackingLinkView 6 | from .views.stats import ( 7 | DailyStatsView, 8 | OffersStatsView, 9 | GoalStatsView, 10 | SubStatsView, 11 | ) 12 | from .views.conversions import ConversionListView 13 | 14 | 15 | urlpatterns = [ 16 | path( 17 | 'profile/', 18 | AffiliateRetrieveAPIView.as_view(), name='affiliate-profile'), 19 | path( 20 | 'sign-up/', 21 | CreateUserView.as_view(), name='affiliate-sign-up'), 22 | path( 23 | 'offers/', OfferListView.as_view(), name='affiliate-offers'), 24 | path( 25 | 'offers//', 26 | OfferRetrieveView.as_view(), name='affiliate-offer'), 27 | path( 28 | 'offers//tracking-link/', 29 | TrackingLinkView.as_view(), name='affiliate-offer-tracking-link'), 30 | path( 31 | 'stats/daily/', 32 | DailyStatsView.as_view(), name='affiliate-stats-daily'), 33 | path( 34 | 'stats/offers/', 35 | OffersStatsView.as_view(), name='affiliate-stats-offers'), 36 | path( 37 | 'conversions/', 38 | ConversionListView.as_view(), name='affiliate-conversions'), 39 | path( 40 | 'stats/by-goal/', 41 | GoalStatsView.as_view(), name='affiliate-stats-by-goal'), 42 | path( 43 | 'stats/by-sub//', 44 | SubStatsView.as_view(), name='affiliate-stats-by-sub'), 45 | ] 46 | -------------------------------------------------------------------------------- /affiliate/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/affiliate/views/__init__.py -------------------------------------------------------------------------------- /affiliate/views/conversions.py: -------------------------------------------------------------------------------- 1 | import iso8601 2 | import pytz 3 | from datetime import datetime, time, date 4 | from rest_framework import serializers 5 | from rest_framework.views import APIView 6 | from rest_framework.response import Response 7 | from rest_framework.permissions import IsAuthenticated 8 | from tracker.models import Conversion 9 | from django.conf import settings 10 | from offer.models import Offer, Goal, Currency 11 | 12 | 13 | class OfferSerializer(serializers.ModelSerializer): 14 | class Meta: 15 | model = Offer 16 | fields = ( 17 | 'id', 18 | 'title', 19 | ) 20 | 21 | 22 | class GoalSerializer(serializers.ModelSerializer): 23 | class Meta: 24 | model = Goal 25 | fields = ( 26 | 'name', 27 | ) 28 | 29 | 30 | class CurrencySerializer(serializers.ModelSerializer): 31 | class Meta: 32 | model = Currency 33 | fields = ( 34 | 'code', 35 | 'name', 36 | ) 37 | 38 | 39 | class ConversionSerializer(serializers.ModelSerializer): 40 | offer = OfferSerializer() 41 | goal = GoalSerializer() 42 | currency = CurrencySerializer() 43 | id = serializers.SerializerMethodField() 44 | click_id = serializers.SerializerMethodField() 45 | 46 | def get_id(self, obj): 47 | return obj.id.hex 48 | 49 | def get_click_id(self, obj): 50 | if obj.click_id: 51 | return obj.click_id.hex 52 | else: 53 | return None 54 | 55 | class Meta: 56 | model = Conversion 57 | fields = ( 58 | 'id', 59 | 'created_at', 60 | 'click_id', 61 | 'offer', 62 | 'payout', 63 | 'sub1', 64 | 'sub2', 65 | 'sub3', 66 | 'sub4', 67 | 'sub5', 68 | 'status', 69 | 'goal', 70 | 'currency', 71 | 'country', 72 | 'ip', 73 | 'ua', 74 | 'comment', 75 | ) 76 | 77 | 78 | class ConversionListView(APIView): 79 | permission_classes = (IsAuthenticated,) 80 | 81 | def get(self, request): 82 | start_date_arg = request.query_params.get('start_date') 83 | end_date_arg = request.query_params.get('end_date') 84 | offer_id = request.query_params.get('offer_id') 85 | 86 | if start_date_arg: 87 | start_date = iso8601.parse_date(start_date_arg) 88 | else: 89 | start_date = date.today() 90 | 91 | if end_date_arg: 92 | end_date = iso8601.parse_date(end_date_arg) 93 | else: 94 | end_date = date.today() 95 | 96 | tz = pytz.timezone(settings.TIME_ZONE) 97 | start_datetime = tz.localize(datetime.combine(start_date, time.min)) 98 | end_datetime = tz.localize(datetime.combine(end_date, time.max)) 99 | 100 | filters = { 101 | 'created_at__range': [start_datetime, end_datetime], 102 | 'affiliate_id': request.user.id, 103 | } 104 | 105 | if offer_id: 106 | filters['offer_id'] = offer_id 107 | 108 | objs = ( 109 | Conversion.objects 110 | .filter(**filters) 111 | .order_by('-created_at') 112 | ) 113 | 114 | return Response(ConversionSerializer(objs, many=True).data) 115 | -------------------------------------------------------------------------------- /affiliate/views/offers.py: -------------------------------------------------------------------------------- 1 | import django_filters 2 | from rest_framework import generics 3 | from rest_framework import serializers 4 | from rest_framework.views import APIView 5 | from rest_framework.response import Response 6 | from rest_framework.permissions import IsAuthenticated 7 | from django_filters.rest_framework import DjangoFilterBackend 8 | from rest_framework.filters import SearchFilter, OrderingFilter 9 | from countries_plus.models import Country 10 | from django.conf import settings 11 | from offer.models import ( 12 | Offer, 13 | Category, 14 | Currency, 15 | Goal, 16 | TrafficSource, 17 | OfferTrafficSource, 18 | Payout 19 | ) 20 | from ..filters import CommaSeparatedTextFilter 21 | 22 | 23 | class CountrySerializer(serializers.ModelSerializer): 24 | class Meta: 25 | model = Country 26 | fields = ( 27 | 'iso', 28 | ) 29 | 30 | 31 | class CategorySerializer(serializers.ModelSerializer): 32 | class Meta: 33 | model = Category 34 | fields = ( 35 | 'name', 36 | ) 37 | 38 | 39 | class TrafficSourceSerializer(serializers.ModelSerializer): 40 | class Meta: 41 | model = TrafficSource 42 | fields = ( 43 | 'name', 44 | ) 45 | 46 | 47 | class OfferTrafficSourceSerializer(serializers.ModelSerializer): 48 | name = serializers.SlugRelatedField( 49 | source='traffic_source', 50 | many=False, read_only=True, slug_field='name' 51 | ) 52 | 53 | class Meta: 54 | model = OfferTrafficSource 55 | fields = ( 56 | 'name', 57 | 'allowed' 58 | ) 59 | 60 | 61 | class GoalSerializer(serializers.ModelSerializer): 62 | class Meta: 63 | model = Goal 64 | fields = ( 65 | 'name', 66 | ) 67 | 68 | 69 | class CurrencySerializer(serializers.ModelSerializer): 70 | class Meta: 71 | model = Currency 72 | fields = ( 73 | 'code', 74 | 'name', 75 | ) 76 | 77 | 78 | class PayoutSerializer(serializers.ModelSerializer): 79 | countries = CountrySerializer(many=True, read_only=True) 80 | goal = GoalSerializer(read_only=True) 81 | currency = CurrencySerializer(read_only=True) 82 | 83 | class Meta: 84 | model = Payout 85 | fields = ( 86 | 'payout', 87 | 'countries', 88 | 'type', 89 | 'currency', 90 | 'goal', 91 | ) 92 | 93 | 94 | class OfferSerializer(serializers.ModelSerializer): 95 | countries = CountrySerializer(many=True, read_only=True) 96 | categories = CategorySerializer(many=True, read_only=True) 97 | traffic_sources = OfferTrafficSourceSerializer( 98 | source='offertrafficsource_set', many=True, read_only=True) 99 | payouts = PayoutSerializer(many=True, read_only=True) 100 | 101 | class Meta: 102 | model = Offer 103 | fields = ( 104 | 'id', 105 | 'title', 106 | 'description', 107 | 'description_html', 108 | 'preview_link', 109 | 'icon', 110 | 'countries', 111 | 'categories', 112 | 'traffic_sources', 113 | 'payouts', 114 | ) 115 | 116 | 117 | class OfferFilterSet(django_filters.FilterSet): 118 | categories = CommaSeparatedTextFilter( 119 | field_name='categories', 120 | help_text='Exact category name or comma-separated names list' 121 | ) 122 | countries = CommaSeparatedTextFilter( 123 | field_name='countries', 124 | help_text='Country 2-character code or comma-separated list' 125 | ) 126 | 127 | 128 | class OfferListView(generics.ListAPIView): 129 | permission_classes = (IsAuthenticated,) 130 | serializer_class = OfferSerializer 131 | queryset = Offer.objects 132 | filter_backends = (DjangoFilterBackend, SearchFilter, OrderingFilter) 133 | filterset_class = OfferFilterSet 134 | search_fields = ['=id', 'title'] 135 | ordering_fields = ['id', 'title'] 136 | 137 | 138 | class OfferRetrieveView(generics.RetrieveAPIView): 139 | permission_classes = (IsAuthenticated,) 140 | serializer_class = OfferSerializer 141 | queryset = Offer.objects 142 | 143 | 144 | def generate_tracking_link(offer_id: int, pid: int) -> str: 145 | base_url = settings.TRACKER_URL 146 | url = f"{base_url}/click?offer_id={offer_id}&pid={pid}" 147 | return url 148 | 149 | 150 | class TrackingLinkView(APIView): 151 | permission_classes = (IsAuthenticated,) 152 | 153 | def get(self, request, pk): 154 | offer_id = pk 155 | user_id = request.user.id 156 | # offer = Offer.objects.get(pk=offer_id) 157 | 158 | url = generate_tracking_link(offer_id, user_id) 159 | return Response({'url': url}) 160 | 161 | # if offer.access == ACCESS_TYPE_PUBLIC: 162 | # url = generate_tracking_link(offer_id, user_id) 163 | # return Response({'url': url}) 164 | 165 | # if offer.access == ACCESS_TYPE_PREMODERATION: 166 | # approved = ( 167 | # Approval.objects 168 | # .filter( 169 | # offer_id=offer_id, 170 | # affiliate_id=user_id, 171 | # status=APPROVAL_STATUS_APPROVED) 172 | # .exists()) 173 | # if approved: 174 | # url = generate_tracking_link(offer_id, user_id) 175 | # return Response({'url': url}) 176 | # else: 177 | # return Response(status=status.HTTP_403_FORBIDDEN) 178 | -------------------------------------------------------------------------------- /affiliate/views/profile.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.response import Response 3 | from rest_framework.generics import RetrieveAPIView 4 | from rest_framework.permissions import IsAuthenticated 5 | # from ..models import Affiliate 6 | from django.contrib.auth import get_user_model 7 | 8 | 9 | class AffiliateModelSerializer(serializers.ModelSerializer): 10 | name = serializers.CharField(source='first_name') 11 | 12 | class Meta: 13 | model = get_user_model() 14 | fields = ( 15 | 'name', 16 | ) 17 | 18 | 19 | class AffiliateRetrieveAPIView(RetrieveAPIView): 20 | permission_classes = (IsAuthenticated,) 21 | serializer_class = AffiliateModelSerializer 22 | 23 | def get(self, request, *args, **kwargs): 24 | serializer = self.get_serializer(self.request.user.affiliate) 25 | return Response(serializer.data) 26 | -------------------------------------------------------------------------------- /affiliate/views/register.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.generics import CreateAPIView 3 | from rest_framework.permissions import AllowAny 4 | from rest_framework.exceptions import ValidationError 5 | from django.contrib.auth import get_user_model 6 | 7 | 8 | def check_username_exists(username: str) -> bool: 9 | try: 10 | get_user_model().objects.get(username=username) 11 | except get_user_model().DoesNotExist: 12 | return False 13 | else: 14 | return True 15 | 16 | 17 | class UserSerializer(serializers.ModelSerializer): 18 | password = serializers.CharField(write_only=True) 19 | confirm_password = serializers.CharField(write_only=True) 20 | 21 | def create(self, validated_data): 22 | if validated_data["password"] != validated_data["confirm_password"]: 23 | raise ValidationError( 24 | {'confirm_password': ['Passwords do not match']} 25 | ) 26 | 27 | if check_username_exists(validated_data['email']): 28 | raise ValidationError( 29 | {'email': [ 30 | f"Email {validated_data['email']} is already registered" 31 | ]} 32 | ) 33 | 34 | user = get_user_model().objects.create( 35 | email=validated_data['email'], 36 | username=validated_data['email'] 37 | ) 38 | user.set_password(validated_data['password']) 39 | user.is_staff = False 40 | user.is_active = True 41 | user.save() 42 | 43 | return user 44 | 45 | class Meta: 46 | model = get_user_model() 47 | fields = ('id', 'email', 'password', 'confirm_password',) 48 | write_only_fields = ('password',) 49 | read_only_fields = ('id',) 50 | 51 | 52 | class CreateUserView(CreateAPIView): 53 | model = get_user_model() 54 | permission_classes = (AllowAny,) 55 | serializer_class = UserSerializer 56 | -------------------------------------------------------------------------------- /affiliate/views/stats.py: -------------------------------------------------------------------------------- 1 | import iso8601 2 | from datetime import datetime, time, timedelta, date 3 | from rest_framework import status 4 | from rest_framework.views import APIView 5 | from rest_framework.response import Response 6 | from rest_framework.permissions import IsAuthenticated 7 | from ..dao import daily_report, offer_report, goal_report, report_bysub 8 | 9 | 10 | class DailyStatsView(APIView): 11 | permission_classes = (IsAuthenticated,) 12 | 13 | def get(self, request): 14 | start_date_arg = request.query_params.get('start_date') 15 | end_date_arg = request.query_params.get('end_date') 16 | offer_id = request.query_params.get('offer_id') 17 | 18 | if start_date_arg: 19 | start_date = iso8601.parse_date(start_date_arg) 20 | else: 21 | start_date = date.today() - timedelta(days=6) 22 | 23 | if end_date_arg: 24 | end_date = iso8601.parse_date(end_date_arg) 25 | else: 26 | end_date = date.today() 27 | 28 | start_datetime = datetime.combine(start_date, time.min) 29 | end_datetime = datetime.combine(end_date, time.max) 30 | 31 | data = daily_report( 32 | request.user.id, start_datetime, end_datetime, offer_id) 33 | 34 | return Response(data) 35 | 36 | 37 | class OffersStatsView(APIView): 38 | permission_classes = (IsAuthenticated,) 39 | 40 | def get(self, request): 41 | start_date_arg = request.query_params.get('start_date') 42 | end_date_arg = request.query_params.get('end_date') 43 | 44 | if start_date_arg: 45 | start_date = iso8601.parse_date(start_date_arg) 46 | else: 47 | start_date = date.today() - timedelta(days=6) 48 | 49 | if end_date_arg: 50 | end_date = iso8601.parse_date(end_date_arg) 51 | else: 52 | end_date = date.today() 53 | 54 | start_datetime = datetime.combine(start_date, time.min) 55 | end_datetime = datetime.combine(end_date, time.max) 56 | 57 | data = offer_report(request.user.id, start_datetime, end_datetime) 58 | 59 | return Response(data) 60 | 61 | 62 | class GoalStatsView(APIView): 63 | permission_classes = (IsAuthenticated,) 64 | 65 | def get(self, request): 66 | start_date_arg = request.query_params.get('start_date') 67 | end_date_arg = request.query_params.get('end_date') 68 | 69 | if start_date_arg: 70 | start_date = iso8601.parse_date(start_date_arg) 71 | else: 72 | start_date = date.today() - timedelta(days=6) 73 | 74 | if end_date_arg: 75 | end_date = iso8601.parse_date(end_date_arg) 76 | else: 77 | end_date = date.today() 78 | 79 | start_datetime = datetime.combine(start_date, time.min) 80 | end_datetime = datetime.combine(end_date, time.max) 81 | 82 | data = goal_report(request.user.id, start_datetime, end_datetime) 83 | 84 | return Response(data) 85 | 86 | 87 | class SubStatsView(APIView): 88 | permission_classes = (IsAuthenticated,) 89 | 90 | def get(self, request, sub): 91 | if sub not in range(1, 6): 92 | return Response(status.HTTP_404_NOT_FOUND) 93 | 94 | offer_id = request.query_params.get('offer_id') 95 | if not offer_id: 96 | return Response( 97 | {'message': 'offer must be specified'}, 98 | status.HTTP_400_BAD_REQUEST 99 | ) 100 | 101 | start_date_arg = request.query_params.get('start_date') 102 | end_date_arg = request.query_params.get('end_date') 103 | 104 | if start_date_arg: 105 | start_date = iso8601.parse_date(start_date_arg) 106 | else: 107 | start_date = date.today() - timedelta(days=6) 108 | 109 | if end_date_arg: 110 | end_date = iso8601.parse_date(end_date_arg) 111 | else: 112 | end_date = date.today() 113 | 114 | start_datetime = datetime.combine(start_date, time.min) 115 | end_datetime = datetime.combine(end_date, time.max) 116 | 117 | data = report_bysub( 118 | sub, offer_id, request.user.id, 119 | start_datetime, end_datetime 120 | ) 121 | 122 | return Response(data) 123 | -------------------------------------------------------------------------------- /api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/api/__init__.py -------------------------------------------------------------------------------- /api/permissions.py: -------------------------------------------------------------------------------- 1 | from rest_framework.permissions import BasePermission 2 | 3 | 4 | class IsSuperUser(BasePermission): 5 | """ 6 | Allows access only to super users. 7 | """ 8 | 9 | def has_permission(self, request, view): 10 | return bool(request.user and request.user.is_superuser) 11 | -------------------------------------------------------------------------------- /api/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/api/tests/__init__.py -------------------------------------------------------------------------------- /api/tests/test_advertiser.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import APITestCase 2 | from django.contrib.auth import get_user_model 3 | 4 | 5 | class AdvertiserTestCase(APITestCase): 6 | url = '/api/advertisers/' 7 | 8 | def setUp(self): 9 | super(AdvertiserTestCase, self).setUp() 10 | credentials = { 11 | 'username': 'test@test.com', 12 | 'password': '1234', 13 | } 14 | self.user = get_user_model().objects.create_user( 15 | id=1, is_staff=True, is_superuser=True, **credentials) 16 | self.client.login(**credentials) 17 | 18 | def test_create(self): 19 | data = { 20 | 'company': 'ROX', 21 | 'email': '...', 22 | 'comment': '...', 23 | } 24 | response = self.client.post(self.url, data, format='json') 25 | self.assertEqual(201, response.status_code) 26 | 27 | offer = response.json() 28 | self.assertEqual(offer['company'], "ROX") 29 | -------------------------------------------------------------------------------- /api/tests/test_conversion.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import APITestCase 2 | from django.contrib.auth import get_user_model 3 | from offer.models import Offer, Currency, Goal 4 | 5 | 6 | class ConversionTestCase(APITestCase): 7 | url = '/api/conversions/' 8 | 9 | def setUp(self): 10 | super(ConversionTestCase, self).setUp() 11 | credentials = { 12 | 'username': 'test@test.com', 13 | 'password': '1234', 14 | } 15 | self.user = get_user_model().objects.create_user( 16 | id=1, is_staff=True, is_superuser=True, **credentials) 17 | self.client.login(**credentials) 18 | Offer.objects.create(id=1, title='a', description='a') 19 | Currency.objects.create(id=1, code="USD", name="Dollar") 20 | Goal.objects.create(id=6, name='Lead') 21 | 22 | def test_create(self): 23 | data = { 24 | "offer_id": 1, 25 | "pid": 1, 26 | "goal": '1', 27 | "goal_id": 6, 28 | "revenue": 32, 29 | "payout": 28, 30 | "currency": 'USD', 31 | "status": "hold", 32 | } 33 | 34 | response = self.client.post(self.url, data, format='json') 35 | self.assertEqual(201, response.status_code) 36 | 37 | conversion = response.json() 38 | self.assertEqual(conversion['offer_id'], 1) 39 | self.assertEqual(conversion['affiliate_id'], 1) 40 | self.assertEqual(conversion['goal_value'], '1') 41 | self.assertEqual(float(conversion['revenue']), 32) 42 | self.assertEqual(float(conversion['payout']), 28) 43 | self.assertEqual(conversion['currency']['code'], 'USD') 44 | self.assertEqual(conversion['status'], 'hold') 45 | self.assertEqual(conversion['goal']['name'], 'Lead') 46 | -------------------------------------------------------------------------------- /api/tests/test_landing.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import APITestCase 2 | from django.contrib.auth import get_user_model 3 | from offer.models import Offer 4 | 5 | 6 | class LandingTestCase(APITestCase): 7 | url = '/api/landings/' 8 | 9 | def setUp(self): 10 | super(LandingTestCase, self).setUp() 11 | credentials = { 12 | 'username': 'test@test.com', 13 | 'password': '1234', 14 | } 15 | self.user = get_user_model().objects.create_user( 16 | id=1, is_staff=True, is_superuser=True, **credentials) 17 | self.client.login(**credentials) 18 | Offer.objects.create(id=1, title='a', description='a') 19 | 20 | def test_create(self): 21 | data = { 22 | 'offer_id': 1, 23 | 'name': 'reg', 24 | 'url': 'https://ya.ru', 25 | 'preview_url': '...', 26 | } 27 | response = self.client.post(self.url, data, format='json') 28 | self.assertEqual(201, response.status_code) 29 | 30 | landing = response.json() 31 | self.assertEqual(landing['name'], 'reg') 32 | -------------------------------------------------------------------------------- /api/tests/test_offer.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import APITestCase 2 | from countries_plus.models import Country 3 | from django.contrib.auth import get_user_model 4 | 5 | 6 | class OfferTestCase(APITestCase): 7 | url = '/api/offers/' 8 | 9 | def setUp(self): 10 | super(OfferTestCase, self).setUp() 11 | credentials = { 12 | 'username': 'test@test.com', 13 | 'password': '1234', 14 | } 15 | self.user = get_user_model().objects.create_user( 16 | id=1, is_staff=True, is_superuser=True, **credentials) 17 | self.client.login(**credentials) 18 | Country.objects.create( 19 | iso='RU', iso3='RUS', iso_numeric=1, name='Russia' 20 | ) 21 | 22 | def test_create(self): 23 | data = { 24 | "title": "test offer", 25 | 'countries': ['RU'] 26 | } 27 | response = self.client.post(self.url, data, format='json') 28 | self.assertEqual(201, response.status_code) 29 | 30 | offer = response.json() 31 | self.assertEqual(offer['title'], "test offer") 32 | -------------------------------------------------------------------------------- /api/tests/test_offer_traffic_source.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import APITestCase 2 | from django.contrib.auth import get_user_model 3 | from offer.models import Offer, TrafficSource 4 | 5 | 6 | class OfferTrafficSourceTestCase(APITestCase): 7 | url = '/api/traffic-sources/' 8 | 9 | def setUp(self): 10 | super(OfferTrafficSourceTestCase, self).setUp() 11 | credentials = { 12 | 'username': 'test@test.com', 13 | 'password': '1234', 14 | } 15 | self.user = get_user_model().objects.create_user( 16 | id=1, is_staff=True, is_superuser=True, **credentials) 17 | self.client.login(**credentials) 18 | Offer.objects.create(id=1, title='a', description='a') 19 | TrafficSource.objects.create(id=1, name='a') 20 | 21 | def test_create(self): 22 | data = { 23 | 'offer_id': 1, 24 | 'traffic_source_id': 1, 25 | 'allowed': False, 26 | } 27 | response = self.client.post(self.url, data, format='json') 28 | self.assertEqual(201, response.status_code) 29 | 30 | offer_traffic_source = response.json() 31 | self.assertEqual(offer_traffic_source['traffic_source']['id'], 1) 32 | self.assertEqual(offer_traffic_source['allowed'], False) 33 | -------------------------------------------------------------------------------- /api/tests/test_payout.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import APITestCase 2 | from countries_plus.models import Country 3 | from django.contrib.auth import get_user_model 4 | from offer.models import Offer, Currency, Goal 5 | 6 | 7 | class PayoutTestCase(APITestCase): 8 | url = '/api/payouts/' 9 | 10 | def setUp(self): 11 | super(PayoutTestCase, self).setUp() 12 | credentials = { 13 | 'username': 'test@test.com', 14 | 'password': '1234', 15 | } 16 | self.user = get_user_model().objects.create_user( 17 | id=1, is_staff=True, is_superuser=True, **credentials) 18 | self.client.login(**credentials) 19 | Offer.objects.create(id=1, title='a', description='a') 20 | Currency.objects.create(id=1, code='USD', name='US Dollar') 21 | Goal.objects.create(id=1, name='Reg') 22 | Country.objects.create( 23 | iso='RU', iso3='RUS', iso_numeric=1, name='Russia' 24 | ) 25 | 26 | def test_create(self): 27 | data = { 28 | 'offer_id': 1, 29 | 'currency_id': 1, 30 | 'goal_id': 1, 31 | 'revenue': '1.0', 32 | 'payout': '0.8', 33 | 'goal_value': '2', 34 | 'countries': ['RU'] 35 | } 36 | response = self.client.post(self.url, data, format='json') 37 | self.assertEqual(201, response.status_code) 38 | 39 | payout = response.json() 40 | self.assertEqual(payout['goal_value'], '2') 41 | # self.assertEqual(payout['offer_id'], '1') 42 | -------------------------------------------------------------------------------- /api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path, include 2 | from rest_framework.routers import DefaultRouter 3 | from .views.conversions import ConversionCreateView 4 | from .views.offer import OfferViewSet 5 | from .views.payout import PayoutViewSet 6 | from .views.landing import LandingViewSet 7 | from .views.offer_traffic_source import OfferTrafficSourceViewSet 8 | from .views.advertiser import AdvertiserViewSet 9 | 10 | 11 | router = DefaultRouter() 12 | router.register(r'offers', OfferViewSet, basename="offer") 13 | router.register(r'advertisers', AdvertiserViewSet, basename="advertiser") 14 | router.register(r'landings', LandingViewSet, basename="landing") 15 | router.register(r'payouts', PayoutViewSet, basename="payout") 16 | router.register( 17 | r'traffic-sources', 18 | OfferTrafficSourceViewSet, basename="traffic-source" 19 | ) 20 | 21 | 22 | urlpatterns = [ 23 | path('', include(router.urls)), 24 | path( 25 | 'conversions/', 26 | ConversionCreateView.as_view(), name='api-conversions'), 27 | ] 28 | -------------------------------------------------------------------------------- /api/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/api/views/__init__.py -------------------------------------------------------------------------------- /api/views/advertiser.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.viewsets import ModelViewSet 3 | from rest_framework.permissions import IsAuthenticated 4 | from ..permissions import IsSuperUser 5 | from offer.models import Advertiser 6 | 7 | 8 | class AdvertiserSerializer(serializers.ModelSerializer): 9 | class Meta: 10 | model = Advertiser 11 | fields = '__all__' 12 | depth = 1 13 | 14 | 15 | class AdvertiserViewSet(ModelViewSet): 16 | permission_classes = (IsAuthenticated, IsSuperUser,) 17 | serializer_class = AdvertiserSerializer 18 | queryset = Advertiser.objects 19 | -------------------------------------------------------------------------------- /api/views/conversions.py: -------------------------------------------------------------------------------- 1 | import rest_framework.status 2 | from rest_framework import serializers 3 | from rest_framework.views import APIView 4 | from rest_framework.response import Response 5 | from rest_framework.permissions import IsAuthenticated 6 | from webargs import core, fields, ValidationError 7 | from webargs.djangoparser import parser 8 | from django.contrib.auth import get_user_model 9 | from tracker.models import ( 10 | Conversion, conversion_statuses, REJECTED_STATUS 11 | ) 12 | from ..permissions import IsSuperUser 13 | from offer.models import Currency, Goal 14 | 15 | 16 | class CurrencySerializer(serializers.ModelSerializer): 17 | class Meta: 18 | model = Currency 19 | fields = ( 20 | 'code', 21 | 'name', 22 | ) 23 | 24 | 25 | class GoalSerializer(serializers.ModelSerializer): 26 | class Meta: 27 | model = Goal 28 | fields = ( 29 | 'id', 30 | 'name', 31 | ) 32 | 33 | 34 | class ConversionSerializer(serializers.ModelSerializer): 35 | currency = CurrencySerializer() 36 | goal = GoalSerializer() 37 | 38 | class Meta: 39 | model = Conversion 40 | fields = ( 41 | 'id', # TODO .hex 42 | 'created_at', 43 | 'offer_id', 44 | 'affiliate_id', 45 | # TODO offer.name 46 | 'revenue', 47 | 'payout', 48 | 'currency', 49 | 'sub1', 50 | 'sub2', 51 | 'sub3', 52 | 'sub4', 53 | 'sub5', 54 | 'status', 55 | 'goal', 56 | 'goal_value', 57 | 'country', 58 | 'ip', 59 | 'ua', 60 | ) 61 | 62 | 63 | @parser.location_loader('data') 64 | def parse_data(request, name, field): 65 | return core.get_value(request.data, name, field) 66 | 67 | 68 | def user_must_exist_in_db(user_id: int) -> None: 69 | try: 70 | get_user_model().objects.get(pk=user_id) 71 | except get_user_model().DoesNotExist: 72 | raise ValidationError("Affiliate does not exist") 73 | 74 | 75 | def status_must_be_known(status: str) -> None: 76 | if status and status not in map(lambda r: r[0], conversion_statuses): 77 | raise ValidationError("Wrong status value") 78 | 79 | 80 | conversion_create_args = { 81 | 'offer_id': fields.Int(required=True), 82 | 'pid': fields.Int(required=True, validate=user_must_exist_in_db), 83 | 'status': fields.Str( 84 | missing=REJECTED_STATUS, 85 | validate=status_must_be_known), 86 | 'currency': fields.Str(missing=''), 87 | 'goal': fields.Str(missing=''), 88 | 'revenue': fields.Float(missing=.0), 89 | 'payout': fields.Float(missing=.0), 90 | 'sub1': fields.Str(missing=''), 91 | 'goal_id': fields.Int(missing=None), 92 | } 93 | 94 | 95 | class ConversionCreateView(APIView): 96 | permission_classes = (IsAuthenticated, IsSuperUser,) 97 | 98 | @parser.use_args(conversion_create_args) 99 | def post(self, request, args): 100 | usr = get_user_model().objects.get(pk=args['pid']) 101 | 102 | conversion = Conversion() 103 | conversion.offer_id = args['offer_id'] 104 | conversion.affiliate_id = args['pid'] 105 | conversion.affiliate_manager = usr.profile.manager 106 | conversion.goal_value = args['goal'] 107 | conversion.revenue = args['revenue'] 108 | conversion.payout = args['payout'] 109 | conversion.sub1 = args['sub1'] 110 | conversion.currency = ( 111 | Currency.objects.filter(code=args['currency']).first()) 112 | conversion.status = args['status'] 113 | if args['goal_id']: 114 | conversion.goal_id = args['goal_id'] 115 | conversion.save() 116 | 117 | return Response( 118 | ConversionSerializer(conversion).data, 119 | status=rest_framework.status.HTTP_201_CREATED 120 | ) 121 | -------------------------------------------------------------------------------- /api/views/landing.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.viewsets import ModelViewSet 3 | from rest_framework.permissions import IsAuthenticated 4 | from ..permissions import IsSuperUser 5 | from offer.models import Landing 6 | 7 | 8 | class LandingSerializer(serializers.ModelSerializer): 9 | offer_id = serializers.IntegerField(write_only=True) 10 | 11 | class Meta: 12 | model = Landing 13 | fields = '__all__' 14 | depth = 1 15 | 16 | 17 | class LandingViewSet(ModelViewSet): 18 | permission_classes = (IsAuthenticated, IsSuperUser,) 19 | serializer_class = LandingSerializer 20 | queryset = Landing.objects 21 | filterset_fields = ['offer_id'] 22 | -------------------------------------------------------------------------------- /api/views/offer.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.viewsets import ModelViewSet 3 | from rest_framework.permissions import IsAuthenticated 4 | from ..permissions import IsSuperUser 5 | from offer.models import Offer 6 | 7 | 8 | class OfferSerializer(serializers.ModelSerializer): 9 | class Meta: 10 | model = Offer 11 | fields = '__all__' 12 | depth = 1 13 | 14 | 15 | class OfferViewSet(ModelViewSet): 16 | permission_classes = (IsAuthenticated, IsSuperUser,) 17 | serializer_class = OfferSerializer 18 | queryset = Offer.objects 19 | -------------------------------------------------------------------------------- /api/views/offer_traffic_source.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.viewsets import ModelViewSet 3 | from rest_framework.permissions import IsAuthenticated 4 | from ..permissions import IsSuperUser 5 | from offer.models import OfferTrafficSource 6 | 7 | 8 | class OfferTrafficSourceSerializer(serializers.ModelSerializer): 9 | offer_id = serializers.IntegerField(write_only=True) 10 | traffic_source_id = serializers.IntegerField(write_only=True) 11 | 12 | class Meta: 13 | model = OfferTrafficSource 14 | fields = '__all__' 15 | depth = 1 16 | 17 | 18 | class OfferTrafficSourceViewSet(ModelViewSet): 19 | permission_classes = (IsAuthenticated, IsSuperUser,) 20 | serializer_class = OfferTrafficSourceSerializer 21 | queryset = OfferTrafficSource.objects 22 | filterset_fields = ['offer_id'] 23 | -------------------------------------------------------------------------------- /api/views/payout.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | from rest_framework.viewsets import ModelViewSet 3 | from rest_framework.permissions import IsAuthenticated 4 | from ..permissions import IsSuperUser 5 | from offer.models import Payout 6 | 7 | 8 | class PayoutSerializer(serializers.ModelSerializer): 9 | offer_id = serializers.IntegerField(write_only=True) 10 | currency_id = serializers.IntegerField(write_only=True) 11 | 12 | class Meta: 13 | model = Payout 14 | fields = '__all__' 15 | depth = 1 16 | 17 | 18 | class PayoutViewSet(ModelViewSet): 19 | permission_classes = (IsAuthenticated, IsSuperUser,) 20 | serializer_class = PayoutSerializer 21 | queryset = Payout.objects 22 | filterset_fields = ['offer_id'] 23 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "scripts": { 3 | "dokku": { 4 | "postdeploy": "python manage.py migrate" 5 | } 6 | } 7 | } -------------------------------------------------------------------------------- /dictionaries/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/dictionaries/__init__.py -------------------------------------------------------------------------------- /dictionaries/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class DictionariesConfig(AppConfig): 5 | name = 'dictionaries' 6 | -------------------------------------------------------------------------------- /dictionaries/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from .views import countries, categories 3 | 4 | 5 | urlpatterns = [ 6 | path('countries/', countries.CountryListView.as_view(), name='countries'), 7 | path( 8 | 'categories/', 9 | categories.CategoryListView.as_view(), name='categories'), 10 | ] 11 | -------------------------------------------------------------------------------- /dictionaries/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/dictionaries/views/__init__.py -------------------------------------------------------------------------------- /dictionaries/views/categories.py: -------------------------------------------------------------------------------- 1 | from rest_framework import generics 2 | from rest_framework import serializers 3 | from rest_framework import permissions 4 | 5 | from offer.models import Category 6 | 7 | 8 | class CategorySerializer(serializers.ModelSerializer): 9 | class Meta: 10 | model = Category 11 | fields = ( 12 | 'id', 13 | 'name', 14 | ) 15 | 16 | 17 | class CategoryListView(generics.ListAPIView): 18 | permission_classes = (permissions.AllowAny,) 19 | serializer_class = CategorySerializer 20 | queryset = Category.objects 21 | -------------------------------------------------------------------------------- /dictionaries/views/countries.py: -------------------------------------------------------------------------------- 1 | from rest_framework import generics 2 | from rest_framework import serializers 3 | from rest_framework import permissions 4 | 5 | from countries_plus.models import Country 6 | 7 | 8 | class CountrySerializer(serializers.ModelSerializer): 9 | class Meta: 10 | model = Country 11 | fields = ( 12 | 'iso', 13 | 'name', 14 | ) 15 | 16 | 17 | class CountryListView(generics.ListAPIView): 18 | permission_classes = (permissions.AllowAny,) 19 | serializer_class = CountrySerializer 20 | queryset = Country.objects 21 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '2' 2 | 3 | 4 | services: 5 | 6 | web: 7 | build: 8 | context: . 9 | dockerfile: ./docker/Dockerfile 10 | image: sandbox/affiliate-platform 11 | command: honcho start --procfile Procfile.dev 12 | volumes: 13 | - .:/app 14 | ports: 15 | - 8000:80 16 | environment: 17 | PYTHONUNBUFFERED: 1 18 | env_file: 19 | - .env 20 | depends_on: 21 | - postgres 22 | - redis 23 | 24 | postgres: 25 | image: "postgres:12-alpine" 26 | environment: 27 | - POSTGRES_DB=postgres 28 | - POSTGRES_USER=postgres 29 | - POSTGRES_PASSWORD=postgres 30 | volumes: 31 | - ./volumes/postgres:/var/lib/postgresql/data 32 | 33 | redis: 34 | image: redis:alpine 35 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3 2 | 3 | RUN pip install --upgrade pip 4 | 5 | WORKDIR /app 6 | 7 | COPY requirements.dev.txt . 8 | 9 | RUN pip install -r requirements.dev.txt 10 | 11 | COPY . . 12 | 13 | ENV PYTHONPATH /app 14 | 15 | EXPOSE 80 16 | 17 | CMD ["python", "manage.py", "runserver", "0.0.0.0:80"] 18 | -------------------------------------------------------------------------------- /docs/api-spec.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/docs/api-spec.png -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Swagger 5 | 6 | 7 | 8 | 9 | 10 |
11 | 12 | 23 | 24 | 25 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /ext/ipapi/__init__.py: -------------------------------------------------------------------------------- 1 | from .api import ( 2 | API, 3 | Err, 4 | TimeoutErr 5 | ) 6 | 7 | 8 | __all__ = [ 9 | 'API', 10 | 'Err', 11 | 'TimeoutErr', 12 | ] 13 | -------------------------------------------------------------------------------- /ext/ipapi/api.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from typing import NamedTuple 3 | 4 | 5 | BASE_URL = "http://ip-api.com/json" 6 | 7 | 8 | class Err(Exception): pass # noqa 9 | class TimeoutErr(Err): pass # noqa 10 | 11 | 12 | class Response(NamedTuple): 13 | country_code: str 14 | 15 | 16 | class API(object): 17 | 18 | def query(self, ip: str) -> Response: 19 | url = f'{BASE_URL}/{ip}' 20 | params = { 21 | 'fields': 'countryCode', 22 | } 23 | try: 24 | resp = requests.get(url, params=params) 25 | except requests.Timeout: 26 | raise TimeoutErr() 27 | if resp.status_code != 200: 28 | raise Err() 29 | data = resp.json() 30 | return Response( 31 | country_code=(data.get('countryCode', '') or ''), 32 | ) 33 | -------------------------------------------------------------------------------- /ext/ipstack/__init__.py: -------------------------------------------------------------------------------- 1 | from .api import ( 2 | API, 3 | Err, 4 | TimeoutErr 5 | ) 6 | 7 | 8 | __all__ = [ 9 | 'API', 10 | 'Err', 11 | 'TimeoutErr', 12 | ] 13 | -------------------------------------------------------------------------------- /ext/ipstack/api.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from typing import NamedTuple 3 | 4 | 5 | BASE_URL = "http://api.ipstack.com/" 6 | 7 | 8 | class Err(Exception): pass # noqa 9 | class TimeoutErr(Err): pass # noqa 10 | 11 | 12 | class Response(NamedTuple): 13 | ip: str 14 | country_code: str 15 | 16 | 17 | class API(object): 18 | 19 | def __init__(self, token): 20 | self.token = token 21 | 22 | def lookup(self, ip: str) -> Response: 23 | url = f'{BASE_URL}/{ip}' 24 | params = { 25 | 'access_key': self.token, 26 | } 27 | try: 28 | resp = requests.get(url, params=params) 29 | except requests.Timeout: 30 | raise TimeoutErr() 31 | if resp.status_code != 200: 32 | raise Err() 33 | data = resp.json() 34 | return Response( 35 | ip=data['ip'], 36 | country_code=data['country_code'] or '', 37 | ) 38 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings') 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == '__main__': 21 | main() 22 | -------------------------------------------------------------------------------- /mypy.ini: -------------------------------------------------------------------------------- 1 | [mypy] 2 | # Mypy configuration: 3 | # https://mypy.readthedocs.io/en/latest/config_file.html 4 | allow_redefinition = False 5 | check_untyped_defs = True 6 | disallow_untyped_decorators = True 7 | disallow_any_explicit = True 8 | disallow_any_generics = True 9 | disallow_untyped_calls = True 10 | ignore_errors = False 11 | ignore_missing_imports = True 12 | implicit_reexport = False 13 | local_partial_types = True 14 | strict_optional = True 15 | strict_equality = True 16 | no_implicit_optional = True 17 | warn_unused_ignores = True 18 | warn_redundant_casts = True 19 | warn_unused_configs = True 20 | warn_unreachable = True 21 | warn_no_return = True 22 | 23 | plugins = 24 | mypy_django_plugin.main, 25 | mypy_drf_plugin.main 26 | 27 | [mypy.plugins.django-stubs] 28 | django_settings_module = "project.settings" 29 | 30 | [mypy-server.apps.*.migrations.*] 31 | # Django migrations should not produce any errors: 32 | ignore_errors = True 33 | 34 | [mypy-server.apps.*.models] 35 | # FIXME: remove this line, when `django-stubs` will stop 36 | # using `Any` inside. 37 | disallow_any_explicit = False -------------------------------------------------------------------------------- /network/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/network/__init__.py -------------------------------------------------------------------------------- /network/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class NetworkConfig(AppConfig): 5 | name = 'network' 6 | -------------------------------------------------------------------------------- /network/dao.py: -------------------------------------------------------------------------------- 1 | import os 2 | from typing import List, Dict 3 | from datetime import datetime 4 | from django.db import connection 5 | 6 | 7 | def daily_report( 8 | user_id: int, start_date: datetime, end_date: datetime, 9 | offer_id: int = 0) -> List[Dict]: 10 | 11 | if offer_id: 12 | offer_filter_clause = f"AND offer_id = {offer_id}" 13 | else: 14 | offer_filter_clause = '' 15 | 16 | filepath = os.path.join( 17 | os.path.dirname(__file__), 'sql', 'daily_report.sql' 18 | ) 19 | with open(filepath, 'r') as f: 20 | sql = f.read().format(**locals()) 21 | 22 | colnames = [ 23 | 'day', 24 | 25 | 'clicks', 26 | 27 | 'approved_qty', 28 | 'approved_revenue', 29 | 'hold_qty', 30 | 'hold_revenue', 31 | 'rejected_qty', 32 | 'rejected_revenue', 33 | 34 | 'cr', 35 | 36 | 'total_qty', 37 | 'total_revenue', 38 | 'total_payout', 39 | 'total_profit', 40 | ] 41 | 42 | with connection.cursor() as cursor: 43 | cursor.execute(sql) 44 | data = cursor.fetchall() 45 | data = [dict(zip(colnames, row)) for row in data] 46 | 47 | return data 48 | 49 | 50 | def offer_report( 51 | user_id: int, start_date: datetime, end_date: datetime) -> List[Dict]: 52 | 53 | colnames = [ 54 | 'offer_id', 55 | 'offer_title', 56 | 57 | 'clicks', 58 | 59 | 'approved_qty', 60 | 'approved_revenue', 61 | 'hold_qty', 62 | 'hold_revenue', 63 | 'rejected_qty', 64 | 'rejected_revenue', 65 | 66 | 'cr', 67 | 68 | 'total_qty', 69 | 'total_revenue', 70 | 'total_payout', 71 | 'total_profit', 72 | ] 73 | 74 | filepath = os.path.join( 75 | os.path.dirname(__file__), 'sql', 'offer_report.sql' 76 | ) 77 | with open(filepath, 'r') as f: 78 | sql = f.read().format(**locals()) 79 | 80 | with connection.cursor() as cursor: 81 | cursor.execute(sql) 82 | data = cursor.fetchall() 83 | data = [dict(zip(colnames, row)) for row in data] 84 | 85 | return data 86 | 87 | 88 | def affiliate_report( 89 | user_id: int, start_date: datetime, end_date: datetime) -> List[Dict]: 90 | 91 | colnames = [ 92 | 'affiliate_id', 93 | 'affiliate_name', 94 | 95 | 'clicks', 96 | 97 | 'approved_qty', 98 | 'approved_revenue', 99 | 'hold_qty', 100 | 'hold_revenue', 101 | 'rejected_qty', 102 | 'rejected_revenue', 103 | 104 | 'cr', 105 | 106 | 'total_qty', 107 | 'total_revenue', 108 | 'total_payout', 109 | 'total_profit', 110 | ] 111 | 112 | filepath = os.path.join( 113 | os.path.dirname(__file__), 'sql', 'affiliate_report.sql' 114 | ) 115 | with open(filepath, 'r') as f: 116 | sql = f.read().format(**locals()) 117 | 118 | with connection.cursor() as cursor: 119 | cursor.execute(sql) 120 | data = cursor.fetchall() 121 | data = [dict(zip(colnames, row)) for row in data] 122 | 123 | return data 124 | -------------------------------------------------------------------------------- /network/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/network/migrations/__init__.py -------------------------------------------------------------------------------- /network/sql/affiliate_report.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | report.affiliate_id, 3 | u.email, 4 | COALESCE(report.clicks, 0), 5 | 6 | COALESCE(report.approved_qty, 0), 7 | COALESCE(report.approved_revenue, 0), 8 | COALESCE(report.hold_qty, 0), 9 | COALESCE(report.hold_revenue, 0), 10 | COALESCE(report.rejected_qty, 0), 11 | COALESCE(report.rejected_revenue, 0), 12 | 13 | COALESCE(report.cr, 0), 14 | 15 | COALESCE(report.total_qty, 0), 16 | COALESCE(report.total_revenue, 0), 17 | COALESCE(report.total_payout, 0), 18 | COALESCE(report.total_profit, 0) 19 | FROM 20 | ( 21 | SELECT 22 | COALESCE(cl.affiliate_id, cv.affiliate_id) as affiliate_id, 23 | cl.clicks, 24 | cv.approved_qty, 25 | cv.approved_revenue, 26 | cv.hold_qty, 27 | cv.hold_revenue, 28 | cv.rejected_qty, 29 | cv.rejected_revenue, 30 | case cl.clicks 31 | when 0 then 0 -- avoid divizion by zero 32 | else (100 * cv.total_qty / cl.clicks) 33 | end AS cr, 34 | cv.total_qty, 35 | cv.total_revenue, 36 | cv.total_payout, 37 | (cv.total_revenue - cv.total_payout) as total_profit 38 | FROM 39 | ( 40 | SELECT 41 | affiliate_id, 42 | count(*) as clicks 43 | FROM tracker_click 44 | WHERE 45 | affiliate_manager_id = {user_id} 46 | AND created_at between '{start_date}' AND '{end_date}' 47 | GROUP BY affiliate_id 48 | ) AS cl 49 | FULL OUTER JOIN 50 | ( 51 | SELECT 52 | affiliate_id, 53 | count(*) AS total_qty, 54 | sum(payout) AS total_payout, 55 | sum(revenue) AS total_revenue, 56 | count(*) FILTER (WHERE status = 'approved') AS approved_qty, 57 | sum(revenue) FILTER (WHERE status = 'approved') AS approved_revenue, 58 | count(*) FILTER (WHERE status = 'hold') AS hold_qty, 59 | sum(revenue) FILTER (WHERE status = 'hold') AS hold_revenue, 60 | count(*) FILTER (WHERE status = 'rejected') AS rejected_qty, 61 | sum(revenue) FILTER (WHERE status = 'rejected') AS rejected_revenue 62 | FROM tracker_conversion 63 | WHERE 64 | affiliate_manager_id = {user_id} 65 | AND created_at between '{start_date}' AND '{end_date}' 66 | GROUP BY affiliate_id 67 | ) AS cv 68 | ON cl.affiliate_id = cv.affiliate_id 69 | ) AS report 70 | LEFT JOIN auth_user AS u 71 | ON report.affiliate_id = u.id 72 | ; -------------------------------------------------------------------------------- /network/sql/daily_report.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | COALESCE(cl.day, cv.day), 3 | COALESCE(cl.clicks, 0), 4 | COALESCE(cv.approved_qty, 0), 5 | COALESCE(cv.approved_revenue, 0), 6 | COALESCE(cv.hold_qty, 0), 7 | COALESCE(cv.hold_revenue, 0), 8 | COALESCE(cv.rejected_qty, 0), 9 | COALESCE(cv.rejected_revenue, 0), 10 | COALESCE( 11 | case cl.clicks 12 | when 0 then 0 -- avoid divizion by zero 13 | else (100 * cv.total_qty / cl.clicks) 14 | end 15 | , 0) AS cr, 16 | COALESCE(cv.total_qty, 0), 17 | COALESCE(cv.total_revenue, 0), 18 | COALESCE(cv.total_payout, 0), 19 | COALESCE(cv.total_revenue - cv.total_payout, 0) as total_profit 20 | FROM 21 | ( 22 | SELECT 23 | created_at::date AS day, 24 | count(*) AS clicks 25 | FROM tracker_click 26 | WHERE 27 | affiliate_manager_id = {user_id} 28 | AND created_at between '{start_date}' AND '{end_date}' 29 | {offer_filter_clause} 30 | GROUP BY day 31 | ) AS cl 32 | FULL OUTER JOIN 33 | ( 34 | SELECT 35 | created_at::date AS day, 36 | count(*) AS total_qty, 37 | sum(payout) AS total_payout, 38 | sum(revenue) AS total_revenue, 39 | count(*) FILTER (WHERE status = 'approved') AS approved_qty, 40 | sum(revenue) FILTER (WHERE status = 'approved') AS approved_revenue, 41 | count(*) FILTER (WHERE status = 'hold') AS hold_qty, 42 | sum(revenue) FILTER (WHERE status = 'hold') AS hold_revenue, 43 | count(*) FILTER (WHERE status = 'rejected') AS rejected_qty, 44 | sum(revenue) FILTER (WHERE status = 'rejected') AS rejected_revenue 45 | FROM tracker_conversion 46 | WHERE 47 | affiliate_manager_id = {user_id} 48 | AND created_at between '{start_date}' AND '{end_date}' 49 | {offer_filter_clause} 50 | GROUP BY day 51 | ) AS cv 52 | ON cl.day = cv.day 53 | ORDER BY cl.day DESC 54 | ; -------------------------------------------------------------------------------- /network/sql/offer_report.sql: -------------------------------------------------------------------------------- 1 | SELECT 2 | report.offer_id, 3 | o.title, 4 | COALESCE(report.clicks, 0), 5 | 6 | COALESCE(report.approved_qty, 0), 7 | COALESCE(report.approved_revenue, 0), 8 | COALESCE(report.hold_qty, 0), 9 | COALESCE(report.hold_revenue, 0), 10 | COALESCE(report.rejected_qty, 0), 11 | COALESCE(report.rejected_revenue, 0), 12 | 13 | COALESCE(report.cr, 0), 14 | 15 | COALESCE(report.total_qty, 0), 16 | COALESCE(report.total_revenue, 0), 17 | COALESCE(report.total_payout, 0), 18 | COALESCE(report.total_profit, 0) 19 | 20 | FROM 21 | ( 22 | SELECT 23 | COALESCE(cl.offer_id, cv.offer_id) as offer_id, 24 | cl.clicks, 25 | cv.approved_qty, 26 | cv.approved_revenue, 27 | cv.hold_qty, 28 | cv.hold_revenue, 29 | cv.rejected_qty, 30 | cv.rejected_revenue, 31 | case cl.clicks 32 | when 0 then 0 -- avoid divizion by zero 33 | else (100 * cv.total_qty / cl.clicks) 34 | end AS cr, 35 | cv.total_qty, 36 | cv.total_revenue, 37 | cv.total_payout, 38 | cv.total_revenue - cv.total_payout as total_profit 39 | FROM 40 | ( 41 | SELECT 42 | offer_id, 43 | count(*) as clicks 44 | FROM tracker_click 45 | WHERE 46 | affiliate_manager_id = {user_id} 47 | AND created_at between '{start_date}' AND '{end_date}' 48 | GROUP BY offer_id 49 | ) AS cl 50 | FULL OUTER JOIN 51 | ( 52 | SELECT 53 | offer_id, 54 | count(*) AS total_qty, 55 | sum(payout) AS total_payout, 56 | sum(revenue) AS total_revenue, 57 | count(*) FILTER (WHERE status = 'approved') AS approved_qty, 58 | sum(revenue) FILTER (WHERE status = 'approved') AS approved_revenue, 59 | count(*) FILTER (WHERE status = 'hold') AS hold_qty, 60 | sum(revenue) FILTER (WHERE status = 'hold') AS hold_revenue, 61 | count(*) FILTER (WHERE status = 'rejected') AS rejected_qty, 62 | sum(revenue) FILTER (WHERE status = 'rejected') AS rejected_revenue 63 | FROM tracker_conversion 64 | WHERE 65 | affiliate_manager_id = {user_id} 66 | AND created_at between '{start_date}' AND '{end_date}' 67 | GROUP BY offer_id 68 | ) AS cv 69 | ON cl.offer_id = cv.offer_id 70 | ) AS report 71 | LEFT JOIN offer_offer AS o 72 | ON report.offer_id = o.id 73 | ; -------------------------------------------------------------------------------- /network/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/network/tests/__init__.py -------------------------------------------------------------------------------- /network/tests/test_affiliate.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import APITestCase 2 | from django.contrib.auth import get_user_model 3 | 4 | 5 | class AffiliateTestCase(APITestCase): 6 | list_url = '/network/affiliates/' 7 | retrieve_url = '/network/affiliates/{}/' 8 | 9 | def get_retrieve_url(self, id_): 10 | return self.retrieve_url.format(id_) 11 | 12 | def setUp(self): 13 | super(AffiliateTestCase, self).setUp() 14 | self.credentials = { 15 | 'username': 'test@test.com', 16 | 'password': '1234', 17 | } 18 | self.user = get_user_model().objects.create_user(**self.credentials) 19 | self.staff_credentials = { 20 | 'username': 'staff', 21 | 'password': '1234', 22 | } 23 | self.staff = ( 24 | get_user_model().objects 25 | .create_user(is_staff=True, **self.staff_credentials) 26 | ) 27 | 28 | def test_not_staff_cant_access_list(self): 29 | self.client.login(**self.credentials) 30 | response = self.client.get(self.list_url) 31 | self.assertEqual(403, response.status_code) 32 | 33 | def test_staff_can_access_list(self): 34 | self.client.login(**self.staff_credentials) 35 | response = self.client.get(self.list_url) 36 | self.assertEqual(200, response.status_code) 37 | self.assertIn('username', response.data[0]) 38 | self.assertIn('email', response.data[0]) 39 | 40 | def test_retrieve(self): 41 | self.client.login(**self.staff_credentials) 42 | response = self.client.get(self.get_retrieve_url(self.user.id)) 43 | self.assertEqual(200, response.status_code) 44 | self.assertIn('username', response.data) 45 | self.assertIn('email', response.data) 46 | -------------------------------------------------------------------------------- /network/tests/test_conversion.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import pytz 3 | import datetime 4 | from rest_framework.test import APITestCase 5 | from django.contrib.auth import get_user_model 6 | from offer.models import Offer 7 | from tracker.models import Conversion 8 | 9 | 10 | class ConversionTestCase(APITestCase): 11 | list_url = '/network/conversions/' 12 | 13 | def setUp(self): 14 | super(ConversionTestCase, self).setUp() 15 | credentials = { 16 | 'username': 'test@test.com', 17 | 'password': '1234', 18 | } 19 | self.user = get_user_model().objects.create_user( 20 | is_staff=True, **credentials) 21 | self.client.login(**credentials) 22 | self.offer = Offer.objects.create( 23 | title='blabla', 24 | description='blabla' 25 | ) 26 | for x in range(3): 27 | Conversion.objects.create( 28 | click_id=uuid.uuid4(), 29 | click_date=datetime.datetime.now(pytz.UTC), 30 | revenue=0.0, 31 | payout=0.0, 32 | ip="1.1.1.1", 33 | affiliate_id=self.user.id, 34 | offer_id=self.offer.id 35 | ) 36 | 37 | def test_list(self): 38 | response = self.client.get(self.list_url) 39 | self.assertEqual(200, response.status_code) 40 | self.assertIn('offer_id', response.data[0]) 41 | self.assertEqual(self.offer.id, response.data[0]['offer_id']) 42 | # self.assertIn('description', response.data[0]) 43 | # self.assertIn('stop_at', response.data[0]) 44 | -------------------------------------------------------------------------------- /network/tests/test_offer.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import APITestCase 2 | from django.contrib.auth import get_user_model 3 | from offer.models import Offer, Advertiser 4 | 5 | 6 | class OfferTestCase(APITestCase): 7 | list_url = '/network/offers/' 8 | retrieve_url = '/network/offers/1/' 9 | 10 | def setUp(self): 11 | super(OfferTestCase, self).setUp() 12 | credentials = { 13 | 'username': 'staff', 14 | 'password': '1234', 15 | } 16 | self.user = ( 17 | get_user_model().objects 18 | .create_user(is_staff=True, **credentials) 19 | ) 20 | self.client.login(**credentials) 21 | self.offers = [ 22 | Offer.objects.create( 23 | id=offer_id, 24 | title='blabla', 25 | description='blabla', 26 | advertiser=( 27 | Advertiser.objects 28 | .create(company='x', email='x', comment='x') 29 | ) 30 | ) for offer_id in range(3) 31 | ] 32 | 33 | def test_list(self): 34 | response = self.client.get(self.list_url) 35 | self.assertEqual(200, response.status_code) 36 | self.assertIn('title', response.data[0]) 37 | self.assertIn('description', response.data[0]) 38 | 39 | def test_retrieve(self): 40 | response = self.client.get(self.retrieve_url) 41 | self.assertEqual(200, response.status_code) 42 | self.assertIn('title', response.data) 43 | self.assertIn('description', response.data) 44 | self.assertIn('tracking_link', response.data) 45 | self.assertIn('preview_link', response.data) 46 | self.assertIn('countries', response.data) 47 | self.assertIn('status', response.data) 48 | self.assertIn('advertiser', response.data) 49 | self.assertIn('company', response.data['advertiser']) 50 | self.assertIn('categories', response.data) 51 | self.assertIn('traffic_sources', response.data) 52 | self.assertIn('payouts', response.data) 53 | -------------------------------------------------------------------------------- /network/tests/test_stats.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | import pytz 3 | import datetime 4 | from rest_framework.test import APITestCase 5 | from django.contrib.auth import get_user_model 6 | from offer.models import Offer 7 | from tracker.models import Click, Conversion 8 | 9 | 10 | class StatsTestCase(APITestCase): 11 | offers_stats_url = '/network/stats/offers/' 12 | daily_stats_url = '/network/stats/daily/' 13 | affiliates_stats_url = '/network/stats/affiliates/' 14 | 15 | def setUp(self): 16 | super(StatsTestCase, self).setUp() 17 | credentials = { 18 | 'username': 'affiliate', 19 | 'password': '1234', 20 | } 21 | self.affiliate = get_user_model().objects.create_user(**credentials) 22 | credentials = { 23 | 'username': 'staff', 24 | 'password': '1234', 25 | } 26 | self.manager = ( 27 | get_user_model().objects 28 | .create_user(is_staff=True, **credentials) 29 | ) 30 | self.client.login(**credentials) 31 | self.offer = Offer.objects.create( 32 | title='blabla', 33 | description='blabla' 34 | ) 35 | sample_values = { 36 | 'affiliate_id': self.affiliate.id, 37 | 'affiliate_manager_id': self.manager.id, 38 | 'offer_id': self.offer.id, 39 | 'ip': '1.1.1.1', 40 | 'revenue': 0.0, 41 | 'payout': 0.0 42 | } 43 | for _ in range(100): 44 | Click.objects.create(**sample_values) 45 | for _ in range(3): 46 | Conversion.objects.create( 47 | click_id=uuid.uuid4(), 48 | click_date=datetime.datetime.now(pytz.UTC), 49 | **sample_values 50 | ) 51 | 52 | def test_offers_stats(self): 53 | response = self.client.get(self.offers_stats_url) 54 | self.assertEqual(200, response.status_code) 55 | self.assertTrue(bool(len(response.data))) 56 | self.assertIn('clicks', response.data[0]) 57 | self.assertTrue(response.data[0]['clicks'] == 100) 58 | self.assertIn('approved_qty', response.data[0]) 59 | self.assertIn('approved_revenue', response.data[0]) 60 | self.assertIn('hold_qty', response.data[0]) 61 | self.assertIn('hold_revenue', response.data[0]) 62 | self.assertIn('rejected_qty', response.data[0]) 63 | self.assertIn('rejected_revenue', response.data[0]) 64 | self.assertIn('cr', response.data[0]) 65 | self.assertIn('total_qty', response.data[0]) 66 | self.assertIn('total_revenue', response.data[0]) 67 | self.assertIn('total_payout', response.data[0]) 68 | self.assertIn('total_profit', response.data[0]) 69 | 70 | def test_daily_stats(self): 71 | response = self.client.get(self.daily_stats_url) 72 | self.assertEqual(200, response.status_code) 73 | self.assertTrue(bool(len(response.data))) 74 | self.assertIn('clicks', response.data[0]) 75 | self.assertTrue(response.data[0]['clicks'] == 100) 76 | self.assertIn('approved_qty', response.data[0]) 77 | self.assertIn('approved_revenue', response.data[0]) 78 | self.assertIn('hold_qty', response.data[0]) 79 | self.assertIn('hold_revenue', response.data[0]) 80 | self.assertIn('rejected_qty', response.data[0]) 81 | self.assertIn('rejected_revenue', response.data[0]) 82 | self.assertIn('cr', response.data[0]) 83 | self.assertIn('total_qty', response.data[0]) 84 | self.assertIn('total_revenue', response.data[0]) 85 | self.assertIn('total_payout', response.data[0]) 86 | self.assertIn('total_profit', response.data[0]) 87 | 88 | def test_affiliates_stats(self): 89 | response = self.client.get(self.affiliates_stats_url) 90 | self.assertEqual(200, response.status_code) 91 | self.assertTrue(bool(len(response.data))) 92 | self.assertIn('affiliate_id', response.data[0]) 93 | self.assertEqual(self.affiliate.id, response.data[0]['affiliate_id']) 94 | self.assertIn('clicks', response.data[0]) 95 | self.assertTrue(response.data[0]['clicks'] == 100) 96 | self.assertIn('approved_qty', response.data[0]) 97 | self.assertIn('approved_revenue', response.data[0]) 98 | self.assertIn('hold_qty', response.data[0]) 99 | self.assertIn('hold_revenue', response.data[0]) 100 | self.assertIn('rejected_qty', response.data[0]) 101 | self.assertIn('rejected_revenue', response.data[0]) 102 | self.assertIn('cr', response.data[0]) 103 | self.assertIn('total_qty', response.data[0]) 104 | self.assertIn('total_revenue', response.data[0]) 105 | self.assertIn('total_payout', response.data[0]) 106 | self.assertIn('total_profit', response.data[0]) 107 | -------------------------------------------------------------------------------- /network/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | from .views.affiliates import AffiliateListView, AffiliateRetrieveView 3 | from .views.offers import OfferListView, OfferRetrieveView 4 | from .views.stats import DailyStatsView, OffersStatsView, AffiliatesStatsView 5 | from .views.conversions import ConversionListView 6 | 7 | 8 | urlpatterns = [ 9 | path( 10 | 'affiliates/', 11 | AffiliateListView.as_view(), name='network-affiliates'), 12 | path( 13 | 'affiliates//', 14 | AffiliateRetrieveView.as_view(), name='network-affiliate'), 15 | path('offers/', OfferListView.as_view(), name='network-offers'), 16 | path( 17 | 'offers//', 18 | OfferRetrieveView.as_view(), name='network-offer'), 19 | path( 20 | 'stats/daily/', 21 | DailyStatsView.as_view(), name='network-stats-daily'), 22 | path( 23 | 'stats/offers/', 24 | OffersStatsView.as_view(), name='network-stats-offers'), 25 | path( 26 | 'stats/affiliates/', 27 | AffiliatesStatsView.as_view(), name='network-stats-affiliates'), 28 | path( 29 | 'conversions/', 30 | ConversionListView.as_view(), name='network-conversions'), 31 | ] 32 | -------------------------------------------------------------------------------- /network/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/network/views/__init__.py -------------------------------------------------------------------------------- /network/views/affiliates.py: -------------------------------------------------------------------------------- 1 | from rest_framework import generics 2 | from rest_framework import serializers 3 | from rest_framework.permissions import IsAuthenticated, IsAdminUser 4 | from django.contrib.auth import get_user_model 5 | 6 | 7 | class AffiliateSerializer(serializers.ModelSerializer): 8 | class Meta: 9 | model = get_user_model() 10 | fields = ( 11 | 'id', 12 | 'username', 13 | 'email', 14 | 'first_name', 15 | 'last_name', 16 | ) 17 | 18 | 19 | class AffiliateListView(generics.ListAPIView): 20 | permission_classes = (IsAuthenticated, IsAdminUser,) 21 | serializer_class = AffiliateSerializer 22 | # queryset = get_user_model().objects 23 | 24 | def get_queryset(self): 25 | return get_user_model().objects.filter(is_staff=False) 26 | 27 | 28 | class AffiliateRetrieveView(generics.RetrieveAPIView): 29 | permission_classes = (IsAuthenticated, IsAdminUser,) 30 | serializer_class = AffiliateSerializer 31 | 32 | def get_queryset(self): 33 | return get_user_model().objects.filter(is_staff=False) 34 | -------------------------------------------------------------------------------- /network/views/conversions.py: -------------------------------------------------------------------------------- 1 | import iso8601 2 | import pytz 3 | from datetime import datetime, time, date 4 | from rest_framework import serializers 5 | from rest_framework.views import APIView 6 | from rest_framework.response import Response 7 | from rest_framework.permissions import IsAuthenticated, IsAdminUser 8 | from tracker.models import Conversion 9 | from django.conf import settings 10 | 11 | 12 | class ConversionSerializer(serializers.ModelSerializer): 13 | 14 | class Meta: 15 | model = Conversion 16 | fields = ( 17 | 'id', # TODO .hex 18 | 'created_at', 19 | 'offer_id', 20 | # TODO offer.name 21 | 'revenue', 22 | 'payout', 23 | 'sub1', 24 | 'sub2', 25 | 'sub3', 26 | 'sub4', 27 | 'sub5', 28 | 'status', 29 | 'goal', 30 | 'goal_value', 31 | 'country', 32 | 'ip', 33 | 'ua', 34 | ) 35 | 36 | 37 | class ConversionListView(APIView): 38 | permission_classes = (IsAuthenticated, IsAdminUser,) 39 | 40 | def get(self, request): 41 | start_date_arg = request.query_params.get('start_date') 42 | end_date_arg = request.query_params.get('end_date') 43 | offer_id = request.query_params.get('offer_id') 44 | 45 | if start_date_arg: 46 | start_date = iso8601.parse_date(start_date_arg) 47 | else: 48 | start_date = date.today() 49 | 50 | if end_date_arg: 51 | end_date = iso8601.parse_date(end_date_arg) 52 | else: 53 | end_date = date.today() 54 | 55 | tz = pytz.timezone(settings.TIME_ZONE) 56 | start_datetime = tz.localize(datetime.combine(start_date, time.min)) 57 | end_datetime = tz.localize(datetime.combine(end_date, time.max)) 58 | 59 | filters = { 60 | 'created_at__range': [start_datetime, end_datetime], 61 | 'affiliate_id': request.user.id, 62 | } 63 | 64 | if offer_id: 65 | filters['offer_id'] = offer_id 66 | 67 | objs = ( 68 | Conversion.objects 69 | .filter(**filters) 70 | .order_by('-created_at') 71 | ) 72 | 73 | return Response(ConversionSerializer(objs, many=True).data) 74 | -------------------------------------------------------------------------------- /network/views/offers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import generics 2 | from rest_framework import serializers 3 | from rest_framework.permissions import IsAuthenticated, IsAdminUser 4 | from countries_plus.models import Country 5 | from offer.models import ( 6 | Offer, 7 | Category, 8 | Advertiser, 9 | Currency, 10 | Goal, 11 | TrafficSource, 12 | OfferTrafficSource, 13 | Payout 14 | ) 15 | 16 | 17 | class CountrySerializer(serializers.ModelSerializer): 18 | class Meta: 19 | model = Country 20 | fields = ( 21 | 'iso', 22 | ) 23 | 24 | 25 | class GoalSerializer(serializers.ModelSerializer): 26 | class Meta: 27 | model = Goal 28 | fields = ( 29 | 'name', 30 | ) 31 | 32 | 33 | class CurrencySerializer(serializers.ModelSerializer): 34 | class Meta: 35 | model = Currency 36 | fields = ( 37 | 'code', 38 | 'name', 39 | ) 40 | 41 | 42 | class PayoutSerializer(serializers.ModelSerializer): 43 | countries = CountrySerializer(many=True, read_only=True) 44 | goal = GoalSerializer(read_only=True) 45 | currency = CurrencySerializer(read_only=True) 46 | 47 | class Meta: 48 | model = Payout 49 | fields = ( 50 | 'revenue', 51 | 'payout', 52 | 'countries', 53 | 'goal_value', 54 | 'type', 55 | 'currency', 56 | 'goal' 57 | ) 58 | 59 | 60 | class CategorySerializer(serializers.ModelSerializer): 61 | class Meta: 62 | model = Category 63 | fields = ( 64 | 'name', 65 | ) 66 | 67 | 68 | class TrafficSourceSerializer(serializers.ModelSerializer): 69 | class Meta: 70 | model = TrafficSource 71 | fields = ( 72 | 'name', 73 | ) 74 | 75 | 76 | class OfferTrafficSourceSerializer(serializers.ModelSerializer): 77 | name = serializers.SlugRelatedField( 78 | source='traffic_source', 79 | many=False, read_only=True, slug_field='name') 80 | 81 | class Meta: 82 | model = OfferTrafficSource 83 | fields = ( 84 | 'name', 85 | 'allowed' 86 | ) 87 | 88 | 89 | class AdvertiserSerializer(serializers.ModelSerializer): 90 | class Meta: 91 | model = Advertiser 92 | fields = ( 93 | 'company', 94 | 'email', 95 | 'contact_person', 96 | 'messenger', 97 | 'site', 98 | 'comment', 99 | ) 100 | 101 | 102 | class OfferSerializer(serializers.ModelSerializer): 103 | countries = CountrySerializer(many=True, read_only=True) 104 | categories = CategorySerializer(many=True, read_only=True) 105 | traffic_sources = OfferTrafficSourceSerializer( 106 | source='offertrafficsource_set', many=True, read_only=True) 107 | advertiser = AdvertiserSerializer(read_only=True) 108 | payouts = PayoutSerializer(many=True, read_only=True) 109 | 110 | class Meta: 111 | model = Offer 112 | fields = ( 113 | 'id', 114 | 'title', 115 | 'description', 116 | 'tracking_link', 117 | 'preview_link', 118 | 'countries', 119 | 'categories', 120 | 'traffic_sources', 121 | 'status', 122 | 'advertiser', 123 | 'payouts', 124 | ) 125 | 126 | 127 | class OfferListView(generics.ListAPIView): 128 | permission_classes = (IsAuthenticated, IsAdminUser,) 129 | serializer_class = OfferSerializer 130 | queryset = Offer.objects 131 | 132 | 133 | class OfferRetrieveView(generics.RetrieveAPIView): 134 | permission_classes = (IsAuthenticated, IsAdminUser,) 135 | serializer_class = OfferSerializer 136 | queryset = Offer.objects 137 | -------------------------------------------------------------------------------- /network/views/stats.py: -------------------------------------------------------------------------------- 1 | import iso8601 2 | from datetime import datetime, time, timedelta, date 3 | from rest_framework.views import APIView 4 | from rest_framework.response import Response 5 | from rest_framework.permissions import IsAuthenticated, IsAdminUser 6 | from ..dao import daily_report, offer_report, affiliate_report 7 | 8 | 9 | class DailyStatsView(APIView): 10 | permission_classes = (IsAuthenticated, IsAdminUser,) 11 | 12 | def get(self, request): 13 | start_date_arg = request.query_params.get('start_date') 14 | end_date_arg = request.query_params.get('end_date') 15 | offer_id = request.query_params.get('offer_id') 16 | 17 | if start_date_arg: 18 | start_date = iso8601.parse_date(start_date_arg) 19 | else: 20 | start_date = date.today() - timedelta(days=6) 21 | 22 | if end_date_arg: 23 | end_date = iso8601.parse_date(end_date_arg) 24 | else: 25 | end_date = date.today() 26 | 27 | start_datetime = datetime.combine(start_date, time.min) 28 | end_datetime = datetime.combine(end_date, time.max) 29 | 30 | data = daily_report( 31 | request.user.id, start_datetime, end_datetime, offer_id) 32 | 33 | return Response(data) 34 | 35 | 36 | class OffersStatsView(APIView): 37 | permission_classes = (IsAuthenticated, IsAdminUser,) 38 | 39 | def get(self, request): 40 | start_date_arg = request.query_params.get('start_date') 41 | end_date_arg = request.query_params.get('end_date') 42 | 43 | if start_date_arg: 44 | start_date = iso8601.parse_date(start_date_arg) 45 | else: 46 | start_date = date.today() - timedelta(days=6) 47 | 48 | if end_date_arg: 49 | end_date = iso8601.parse_date(end_date_arg) 50 | else: 51 | end_date = date.today() 52 | 53 | start_datetime = datetime.combine(start_date, time.min) 54 | end_datetime = datetime.combine(end_date, time.max) 55 | 56 | data = offer_report(request.user.id, start_datetime, end_datetime) 57 | 58 | return Response(data) 59 | 60 | 61 | class AffiliatesStatsView(APIView): 62 | permission_classes = (IsAuthenticated, IsAdminUser,) 63 | 64 | def get(self, request): 65 | start_date_arg = request.query_params.get('start_date') 66 | end_date_arg = request.query_params.get('end_date') 67 | 68 | if start_date_arg: 69 | start_date = iso8601.parse_date(start_date_arg) 70 | else: 71 | start_date = date.today() - timedelta(days=6) 72 | 73 | if end_date_arg: 74 | end_date = iso8601.parse_date(end_date_arg) 75 | else: 76 | end_date = date.today() 77 | 78 | start_datetime = datetime.combine(start_date, time.min) 79 | end_datetime = datetime.combine(end_date, time.max) 80 | 81 | data = affiliate_report(request.user.id, start_datetime, end_datetime) 82 | 83 | return Response(data) 84 | -------------------------------------------------------------------------------- /offer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/offer/__init__.py -------------------------------------------------------------------------------- /offer/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import ( 3 | Offer, 4 | Category, 5 | TrafficSource, 6 | OfferTrafficSource, 7 | Goal, 8 | Currency, 9 | Payout, 10 | Landing, 11 | Advertiser, 12 | STOPPED_STATUS, 13 | ) 14 | 15 | 16 | class OfferTrafficSource_inline(admin.TabularInline): 17 | model = OfferTrafficSource 18 | extra = 1 19 | 20 | 21 | class Payout_inline(admin.TabularInline): 22 | model = Payout 23 | extra = 1 24 | 25 | 26 | class Landing_inline(admin.TabularInline): 27 | model = Landing 28 | extra = 1 29 | 30 | 31 | def duplicate_offer(modeladmin, request, queryset): 32 | for offer in queryset: 33 | source_payouts = [p for p in offer.payouts.all()] 34 | source_countries = [c for c in offer.countries.all()] 35 | source_categories = [c for c in offer.categories.all()] 36 | source_ots = [ots for ots in offer.offertrafficsource_set.all()] 37 | 38 | offer.id = None 39 | offer.title = offer.title + ' DUPLICATE' 40 | offer.status = STOPPED_STATUS 41 | offer.save() 42 | 43 | for country in source_countries: 44 | offer.countries.add(country) 45 | 46 | for category in source_categories: 47 | offer.categories.add(category) 48 | 49 | for payout in source_payouts: 50 | source_payout_countries = [c for c in payout.countries.all()] 51 | 52 | payout.id = None 53 | payout.save() 54 | 55 | for country in source_payout_countries: 56 | payout.countries.add(country) 57 | 58 | offer.payouts.add(payout) 59 | 60 | for ots in source_ots: 61 | ots.id = None 62 | ots.save() 63 | offer.offertrafficsource_set.add(ots) 64 | # queryset.update(status=PAUSED_STATUS) 65 | 66 | 67 | duplicate_offer.short_description = "Duplicate" 68 | 69 | 70 | @admin.register(Offer) 71 | class OfferAdmin(admin.ModelAdmin): 72 | inlines = ( 73 | OfferTrafficSource_inline, 74 | Payout_inline, 75 | Landing_inline, 76 | ) 77 | list_display = ( 78 | 'title', 'id', 'status', 'advertiser', 79 | ) 80 | actions = [duplicate_offer] 81 | 82 | 83 | @admin.register(Category) 84 | class CategoryAdmin(admin.ModelAdmin): 85 | pass 86 | 87 | 88 | @admin.register(TrafficSource) 89 | class TrafficSourceAdmin(admin.ModelAdmin): 90 | pass 91 | 92 | 93 | @admin.register(Goal) 94 | class GoalAdmin(admin.ModelAdmin): 95 | pass 96 | 97 | 98 | @admin.register(Payout) 99 | class PayoutAdmin(admin.ModelAdmin): 100 | list_display = ( 101 | 'offer', 'revenue', 'payout', 'currency', 102 | 'goal_value', 'goal', 103 | ) 104 | 105 | 106 | @admin.register(Currency) 107 | class CurrencyAdmin(admin.ModelAdmin): 108 | pass 109 | 110 | 111 | @admin.register(Advertiser) 112 | class AdvertiserAdmin(admin.ModelAdmin): 113 | pass 114 | -------------------------------------------------------------------------------- /offer/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class OfferConfig(AppConfig): 5 | name = 'offer' 6 | -------------------------------------------------------------------------------- /offer/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-21 20:33 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Offer', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('title', models.CharField(max_length=256)), 19 | ], 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /offer/migrations/0002_offer_countries.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-21 21:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('countries_plus', '0005_auto_20160224_1804'), 10 | ('offer', '0001_initial'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='offer', 16 | name='countries', 17 | field=models.ManyToManyField(to='countries_plus.Country'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /offer/migrations/0003_offer_description.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-09-24 20:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('offer', '0002_offer_countries'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='offer', 15 | name='description', 16 | field=models.TextField(default=''), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /offer/migrations/0004_auto_20191009_1712.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.5 on 2019-10-09 17:12 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('offer', '0003_offer_description'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='offer', 15 | name='title', 16 | field=models.CharField(default='', max_length=256), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /offer/migrations/0005_category.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-18 16:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('offer', '0004_auto_20191009_1712'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Category', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('name', models.CharField(max_length=256)), 18 | ], 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /offer/migrations/0006_trafficsource.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-18 16:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('offer', '0005_category'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='TrafficSource', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('name', models.CharField(max_length=256)), 18 | ], 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /offer/migrations/0007_offer_categories.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-18 16:46 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('offer', '0006_trafficsource'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='offer', 15 | name='categories', 16 | field=models.ManyToManyField(to='offer.Category'), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /offer/migrations/0008_auto_20200618_1647.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-18 16:47 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('offer', '0007_offer_categories'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='OfferTrafficSource', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('allowed', models.BooleanField(default=True)), 19 | ('offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='offer.Offer')), 20 | ('traffic_source', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='offer.TrafficSource')), 21 | ], 22 | ), 23 | migrations.AddField( 24 | model_name='offer', 25 | name='traffic_sources', 26 | field=models.ManyToManyField(through='offer.OfferTrafficSource', to='offer.TrafficSource'), 27 | ), 28 | ] 29 | -------------------------------------------------------------------------------- /offer/migrations/0009_auto_20200618_1706.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-18 17:06 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('offer', '0008_auto_20200618_1647'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Goal', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('name', models.CharField(max_length=64)), 18 | ], 19 | ), 20 | migrations.AlterField( 21 | model_name='offer', 22 | name='categories', 23 | field=models.ManyToManyField(blank=True, to='offer.Category'), 24 | ), 25 | migrations.AlterField( 26 | model_name='offer', 27 | name='traffic_sources', 28 | field=models.ManyToManyField(blank=True, through='offer.OfferTrafficSource', to='offer.TrafficSource'), 29 | ), 30 | ] 31 | -------------------------------------------------------------------------------- /offer/migrations/0010_currency.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-18 17:15 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('offer', '0009_auto_20200618_1706'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Currency', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('code', models.CharField(max_length=3)), 18 | ('name', models.CharField(max_length=128)), 19 | ], 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /offer/migrations/0011_payout.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-18 17:16 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('countries_plus', '0005_auto_20160224_1804'), 11 | ('offer', '0010_currency'), 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Payout', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('revenue', models.DecimalField(decimal_places=2, max_digits=7)), 20 | ('payout', models.DecimalField(decimal_places=2, max_digits=7)), 21 | ('goal_value', models.CharField(default='1', max_length=20)), 22 | ('type', models.CharField(choices=[('Fixed', 'Fixed'), ('Percent', 'Percent')], default='Fixed', max_length=20)), 23 | ('countries', models.ManyToManyField(to='countries_plus.Country')), 24 | ('currency', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='offer.Currency')), 25 | ('goal', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='offer.Goal')), 26 | ('offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='payouts', to='offer.Offer')), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /offer/migrations/0012_auto_20200618_1922.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-18 19:22 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('offer', '0011_payout'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='offer', 15 | name='status', 16 | field=models.CharField(choices=[('Active', 'Active'), ('Paused', 'Paused'), ('Stopped', 'Stopped')], default='Active', max_length=20), 17 | ), 18 | migrations.AddField( 19 | model_name='offer', 20 | name='tracking_link', 21 | field=models.CharField(default='', max_length=1024), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /offer/migrations/0013_offer_preview_link.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-18 19:22 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('offer', '0012_auto_20200618_1922'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='offer', 15 | name='preview_link', 16 | field=models.CharField(default='', max_length=1024), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /offer/migrations/0014_advertiser.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-18 19:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('offer', '0013_offer_preview_link'), 10 | ] 11 | 12 | operations = [ 13 | migrations.CreateModel( 14 | name='Advertiser', 15 | fields=[ 16 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 17 | ('company', models.CharField(max_length=64)), 18 | ('email', models.CharField(max_length=64)), 19 | ('contact_person', models.CharField(default='', max_length=64)), 20 | ('messenger', models.CharField(default='', max_length=64)), 21 | ('site', models.CharField(default='', max_length=64)), 22 | ('comment', models.TextField()), 23 | ], 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /offer/migrations/0015_offer_advertiser.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-18 19:27 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('offer', '0014_advertiser'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='offer', 16 | name='advertiser', 17 | field=models.ForeignKey(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, to='offer.Advertiser'), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /offer/migrations/0016_auto_20200629_2215.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-06-29 22:15 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('offer', '0015_offer_advertiser'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='payout', 15 | options={'ordering': ('-payout',)}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /offer/migrations/0017_offer_icon.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-07-26 17:40 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('offer', '0016_auto_20200629_2215'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='offer', 15 | name='icon', 16 | field=models.CharField(blank=True, default=None, max_length=255, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /offer/migrations/0018_auto_20200726_1815.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-07-26 18:15 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('offer', '0017_offer_icon'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='offer', 15 | options={'ordering': ('-id',)}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /offer/migrations/0019_offer_description_html.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-07-27 10:40 2 | 3 | from django.db import migrations 4 | import tinymce.models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('offer', '0018_auto_20200726_1815'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='offer', 16 | name='description_html', 17 | field=tinymce.models.HTMLField(blank=True, default=''), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /offer/migrations/0020_landing.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-09-01 23:14 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('offer', '0019_offer_description_html'), 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Landing', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('name', models.CharField(max_length=100)), 19 | ('url', models.CharField(default='', max_length=1024)), 20 | ('preview_url', models.CharField(default='', max_length=1024)), 21 | ('offer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='landings', to='offer.Offer')), 22 | ], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /offer/migrations/0021_auto_20200903_2149.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-09-03 21:49 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('offer', '0020_landing'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='offer', 15 | name='description', 16 | field=models.TextField(blank=True, default=''), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /offer/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/offer/migrations/__init__.py -------------------------------------------------------------------------------- /offer/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from countries_plus.models import Country 3 | from tinymce import models as tinymce_models 4 | 5 | 6 | ACTIVE_STATUS = 'Active' 7 | PAUSED_STATUS = 'Paused' 8 | STOPPED_STATUS = 'Stopped' 9 | offer_statuses = ( 10 | (ACTIVE_STATUS, 'Active'), 11 | (PAUSED_STATUS, 'Paused'), 12 | (STOPPED_STATUS, 'Stopped'), 13 | ) 14 | 15 | 16 | class Offer(models.Model): 17 | 18 | class Meta: 19 | ordering = ('-id',) 20 | 21 | title = models.CharField(max_length=256, default='') 22 | description = models.TextField(blank=True, default='') 23 | description_html = tinymce_models.HTMLField(default='', blank=True) 24 | tracking_link = models.CharField(max_length=1024, default='') 25 | preview_link = models.CharField(max_length=1024, default='') 26 | countries = models.ManyToManyField(Country) 27 | categories = models.ManyToManyField('Category', blank=True) 28 | traffic_sources = models.ManyToManyField( 29 | 'TrafficSource', through='OfferTrafficSource', blank=True) 30 | status = models.CharField(max_length=20, choices=offer_statuses, 31 | default=ACTIVE_STATUS) 32 | icon = models.CharField(max_length=255, 33 | default=None, blank=True, null=True) 34 | advertiser = models.ForeignKey( 35 | 'Advertiser', 36 | on_delete=models.SET_NULL, 37 | null=True, blank=True, default=None) 38 | 39 | def __str__(self): 40 | return f"({self.id}) {self.title}" 41 | 42 | 43 | class Category(models.Model): 44 | name = models.CharField(max_length=256) 45 | 46 | def __str__(self): 47 | return self.name 48 | 49 | 50 | class TrafficSource(models.Model): 51 | name = models.CharField(max_length=256) 52 | 53 | def __str__(self): 54 | return self.name 55 | 56 | 57 | class OfferTrafficSource(models.Model): 58 | offer = models.ForeignKey(Offer, on_delete=models.CASCADE) 59 | traffic_source = models.ForeignKey(TrafficSource, on_delete=models.CASCADE) 60 | allowed = models.BooleanField(default=True) 61 | 62 | 63 | FIXED_PAYOUT = 'Fixed' 64 | PERCENT_PAYOUT = 'Percent' 65 | payout_types = ( 66 | (FIXED_PAYOUT, 'Fixed'), 67 | (PERCENT_PAYOUT, 'Percent'), 68 | ) 69 | 70 | 71 | class Goal(models.Model): 72 | name = models.CharField(max_length=64) 73 | 74 | def __str__(self): 75 | return self.name 76 | 77 | 78 | class Currency(models.Model): 79 | code = models.CharField(max_length=3) 80 | name = models.CharField(max_length=128) 81 | 82 | def __str__(self): 83 | return self.name 84 | 85 | 86 | class Payout(models.Model): 87 | 88 | class Meta: 89 | ordering = ('-payout',) 90 | 91 | revenue = models.DecimalField(max_digits=7, decimal_places=2) 92 | payout = models.DecimalField(max_digits=7, decimal_places=2) 93 | countries = models.ManyToManyField(Country) 94 | goal_value = models.CharField(max_length=20, default='1') 95 | type = models.CharField( 96 | max_length=20, 97 | choices=payout_types, 98 | default=FIXED_PAYOUT 99 | ) 100 | 101 | currency = models.ForeignKey( 102 | Currency, 103 | on_delete=models.CASCADE 104 | ) 105 | 106 | goal = models.ForeignKey( 107 | Goal, 108 | on_delete=models.SET_NULL, 109 | null=True, 110 | blank=True, 111 | ) 112 | 113 | offer = models.ForeignKey( 114 | Offer, 115 | related_name='payouts', 116 | on_delete=models.CASCADE 117 | ) 118 | 119 | 120 | class Advertiser(models.Model): 121 | company = models.CharField(max_length=64) 122 | email = models.CharField(max_length=64) 123 | contact_person = models.CharField(max_length=64, default='') 124 | messenger = models.CharField(max_length=64, default='') 125 | site = models.CharField(max_length=64, default='') 126 | comment = models.TextField() 127 | 128 | def __str__(self): 129 | return self.company 130 | 131 | 132 | class Landing(models.Model): 133 | name = models.CharField(max_length=100) 134 | url = models.CharField(max_length=1024, default='') 135 | preview_url = models.CharField(max_length=1024, default='') 136 | 137 | offer = models.ForeignKey( 138 | Offer, 139 | related_name='landings', 140 | on_delete=models.CASCADE 141 | ) 142 | 143 | def __str__(self): 144 | return f'{self.id}: {self.name}' 145 | -------------------------------------------------------------------------------- /offer/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | from .cache_offers import cache_offers # noqa 2 | -------------------------------------------------------------------------------- /offer/tasks/cache_offers.py: -------------------------------------------------------------------------------- 1 | import json 2 | import redis 3 | from project._celery import _celery 4 | from project.redis_conn import pool 5 | from ..models import Offer 6 | 7 | 8 | @_celery.task 9 | def cache_offers(): 10 | redis_conn = redis.Redis(connection_pool=pool) 11 | for offer in Offer.objects.all(): 12 | record = { 13 | 'tracking_link': offer.tracking_link 14 | } 15 | redis_conn.set(f'offers:{offer.id}', json.dumps(record)) 16 | -------------------------------------------------------------------------------- /permissions.txt: -------------------------------------------------------------------------------- 1 | every api defines it's own permissions 2 | 3 | 4 | User -* Groups (aka Roles) 5 | 6 | 7 | Permission(models.Model): 8 | name 9 | app 10 | groups** 11 | 12 | 13 | Apis: 14 | Affiliate 15 | Network 16 | AffiliateReport 17 | NetworkReport 18 | 19 | 20 | Роли 21 | аф манагер 22 | Affiliate 23 | read all 24 | write all 25 | Offer 26 | read only 27 | Advertiser 28 | no read 29 | 30 | админ 31 | * -------------------------------------------------------------------------------- /postback/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/postback/__init__.py -------------------------------------------------------------------------------- /postback/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Postback, Log 3 | 4 | 5 | @admin.register(Postback) 6 | class PostbackAdmin(admin.ModelAdmin): 7 | list_display = ( 8 | 'id', 9 | 'affiliate', 10 | 'offer', 11 | 'status', 12 | 'goal', 13 | 'url', 14 | ) 15 | 16 | 17 | @admin.register(Log) 18 | class LogAdmin(admin.ModelAdmin): 19 | list_display = ( 20 | 'created_at', 21 | 'affiliate', 22 | 'url', 23 | 'response_status', 24 | ) 25 | -------------------------------------------------------------------------------- /postback/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class PostbackConfig(AppConfig): 5 | name = 'postback' 6 | -------------------------------------------------------------------------------- /postback/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-08-26 22:08 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ('offer', '0019_offer_description_html'), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name='Postback', 20 | fields=[ 21 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 22 | ('url', models.CharField(max_length=500)), 23 | ('status', models.CharField(choices=[('created', 'Created'), ('not_found', 'Not Found'), ('approved', 'Approved'), ('hold', 'Hold'), ('rejected', 'Rejected')], default='created', max_length=20)), 24 | ('goal', models.CharField(default='', max_length=20)), 25 | ('affiliate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='postbacks', to=settings.AUTH_USER_MODEL)), 26 | ('offer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='postbacks', to='offer.Offer')), 27 | ], 28 | ), 29 | ] 30 | -------------------------------------------------------------------------------- /postback/migrations/0002_log.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-08-26 22:13 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('postback', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.CreateModel( 17 | name='Log', 18 | fields=[ 19 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 20 | ('created_at', models.DateTimeField(auto_now_add=True)), 21 | ('url', models.CharField(max_length=500)), 22 | ('response_status', models.CharField(default='', max_length=10)), 23 | ('response_text', models.TextField()), 24 | ('affiliate', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='postback_logs', to=settings.AUTH_USER_MODEL)), 25 | ], 26 | ), 27 | ] 28 | -------------------------------------------------------------------------------- /postback/migrations/0003_auto_20200827_1244.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-08-27 12:44 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('postback', '0002_log'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='postback', 15 | name='goal', 16 | field=models.CharField(blank=True, default='', max_length=20), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /postback/migrations/0004_auto_20201018_0101.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-10-18 01:01 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('postback', '0003_auto_20200827_1244'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='postback', 15 | name='status', 16 | field=models.CharField(choices=[('created', 'Created'), ('not_found', 'Not Found'), ('approved', 'Approved'), ('hold', 'Hold'), ('rejected', 'Rejected'), ('pending', 'Pending')], default='created', max_length=20), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /postback/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/postback/migrations/__init__.py -------------------------------------------------------------------------------- /postback/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | from django.contrib.auth import get_user_model 3 | from offer.models import Offer 4 | from tracker.models import conversion_statuses 5 | 6 | 7 | CREATED_STATUS = 'created' 8 | postback_statuses = ( 9 | ('created', 'Created',), 10 | ('not_found', 'Not Found',), 11 | ) + conversion_statuses 12 | 13 | 14 | class Postback(models.Model): 15 | url = models.CharField(max_length=500) 16 | status = models.CharField( 17 | max_length=20, 18 | choices=postback_statuses, 19 | default=CREATED_STATUS) 20 | goal = models.CharField(max_length=20, blank=True, default="") 21 | 22 | offer = models.ForeignKey( 23 | Offer, 24 | related_name='postbacks', 25 | on_delete=models.CASCADE, 26 | null=True, 27 | blank=True 28 | ) 29 | 30 | affiliate = models.ForeignKey( 31 | get_user_model(), 32 | related_name='postbacks', 33 | on_delete=models.CASCADE, 34 | ) 35 | 36 | 37 | class Log(models.Model): 38 | created_at = models.DateTimeField(auto_now_add=True) 39 | url = models.CharField(max_length=500) 40 | response_status = models.CharField(max_length=10, default='') 41 | response_text = models.TextField() 42 | 43 | affiliate = models.ForeignKey( 44 | get_user_model(), 45 | related_name='postback_logs', 46 | on_delete=models.CASCADE, 47 | ) 48 | -------------------------------------------------------------------------------- /postback/tasks/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/postback/tasks/__init__.py -------------------------------------------------------------------------------- /postback/tasks/send_postback.py: -------------------------------------------------------------------------------- 1 | import requests 2 | from project._celery import _celery 3 | from ..models import Postback, Log 4 | 5 | 6 | def find_postbacks(affiliate_id, offer_id=None): 7 | return ( 8 | Postback.objects 9 | .filter(affiliate_id=affiliate_id, offer_id=offer_id) 10 | ) 11 | 12 | 13 | @_celery.task 14 | def send_postback(conversion): 15 | assert bool(conversion['offer_id']) 16 | assert bool(conversion['affiliate_id']) 17 | 18 | offer_id = conversion['offer_id'] 19 | affiliate_id = conversion['affiliate_id'] 20 | 21 | postbacks = find_postbacks(offer_id=offer_id, affiliate_id=affiliate_id) 22 | if not postbacks: 23 | postbacks = find_postbacks(affiliate_id=affiliate_id) 24 | 25 | for postback in postbacks: 26 | if postback.goal: 27 | if conversion['goal_value'] != postback.goal: 28 | continue 29 | 30 | url = replace_macro(postback.url, conversion) 31 | 32 | try: 33 | resp = requests.get(url) 34 | 35 | persist_log(affiliate_id, url, str(resp.status_code), resp.text) 36 | except requests.exceptions.Timeout: 37 | persist_log(affiliate_id, url, '', 'Timeout') 38 | except Exception as e: 39 | persist_log(affiliate_id, url, '', str(e)) 40 | 41 | 42 | def persist_log(affiliate_id: int, url: str, status: str, text: str): 43 | log = Log() 44 | log.affiliate_id = affiliate_id 45 | log.url = url 46 | log.response_status = status 47 | log.response_text = text 48 | log.save() 49 | 50 | 51 | def replace_macro(url: str, data: dict) -> str: 52 | url = url.replace('{sub1}', data.get('sub1', '')) 53 | url = url.replace('{sub2}', data.get('sub2', '')) 54 | url = url.replace('{sub3}', data.get('sub3', '')) 55 | url = url.replace('{sub4}', data.get('sub4', '')) 56 | url = url.replace('{sub5}', data.get('sub5', '')) 57 | url = url.replace('{offer}', str(data.get('offer_id', ''))) 58 | url = url.replace('{sum}', str(data.get('payout', ''))) 59 | url = url.replace('{currency}', data.get('currency', '') or '') 60 | url = url.replace('{goal}', data.get('goal_value', '')) 61 | 62 | return url 63 | -------------------------------------------------------------------------------- /postback/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/postback/tests/__init__.py -------------------------------------------------------------------------------- /postback/tests/test_send_postback.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | from django.test import TestCase 3 | from ..tasks.send_postback import send_postback 4 | from ..models import Postback 5 | 6 | 7 | @patch('postback.tasks.send_postback.persist_log', autospec=True) 8 | @patch('postback.tasks.send_postback.requests', autospec=True) 9 | @patch('postback.tasks.send_postback.find_postbacks', autospec=True) 10 | class TestSendPostback(TestCase): 11 | 12 | def setUp(self): 13 | super(TestSendPostback, self).setUp() 14 | 15 | def test_doesnt_send_postback( 16 | self, mocked_find_postbacks, mocked_requests, mocked_persist_log): 17 | mocked_find_postbacks.return_value = [] 18 | conversion = { 19 | 'offer_id': 1, 20 | 'affiliate_id': 2, 21 | } 22 | send_postback(conversion) 23 | assert not mocked_requests.get.called, 'requests should not be called' 24 | assert not mocked_persist_log.called, 'log should not be called' 25 | 26 | def test_sends_postback( 27 | self, mocked_find_postbacks, mocked_requests, mocked_persist_log): 28 | url = "http://example.com/path?sub1={sub1}&sum={sum}" 29 | postback = Postback(url=url) 30 | mocked_find_postbacks.return_value = [postback] 31 | sub1 = '7a5b75c757d7c76d5c7' 32 | payout = 0.15 33 | conversion = { 34 | 'offer_id': 1, 35 | 'affiliate_id': 2, 36 | 'payout': payout, 37 | 'currency': 'USD', 38 | 'goal_value': '2', 39 | 'sub1': sub1, 40 | } 41 | send_postback(conversion) 42 | expected_url = f'http://example.com/path?sub1={sub1}&sum={payout}' 43 | ( 44 | mocked_requests.get.assert_called_with(expected_url), 45 | 'requests should be called' 46 | ) 47 | assert mocked_persist_log.called, 'persist log should be called' 48 | 49 | def test_goal_should_not_send_if_goal_doesnt_match( 50 | self, mocked_find_postbacks, mocked_requests, mocked_persist_log): 51 | postback = Postback(goal='2') 52 | mocked_find_postbacks.return_value = [postback] 53 | conversion = { 54 | 'offer_id': 1, 55 | 'affiliate_id': 2, 56 | 'goal_value': '1', 57 | } 58 | send_postback(conversion) 59 | assert not mocked_requests.get.called, 'requests should not be called' 60 | 61 | def test_goal_should_send_if_goal_match( 62 | self, mocked_find_postbacks, mocked_requests, mocked_persist_log): 63 | postback = Postback(goal='2') 64 | mocked_find_postbacks.return_value = [postback] 65 | conversion = { 66 | 'offer_id': 1, 67 | 'affiliate_id': 2, 68 | 'goal_value': '2', 69 | } 70 | send_postback(conversion) 71 | assert mocked_requests.get.called, 'requests should be called' 72 | -------------------------------------------------------------------------------- /project/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/project/__init__.py -------------------------------------------------------------------------------- /project/_celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | from celery import Celery 3 | 4 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings') 5 | 6 | from django.conf import settings # noqa 7 | 8 | 9 | _celery = Celery( 10 | 'cpa-platform', 11 | broker=settings.REDIS_URL 12 | ) 13 | 14 | _celery.config_from_object('django.conf:settings', namespace='CELERY') 15 | 16 | _celery.autodiscover_tasks() 17 | 18 | # task_routes = { 19 | # 'campaigns.tasks.stats.push_sent': {'queue': 'stats:pushes'}, 20 | # 'tracker.tasks.stats.*': {'queue': 'stats'}, 21 | # } 22 | 23 | _celery.conf.update( 24 | task_serializer='json', 25 | accept_content=['json'], # Ignore other content 26 | timezone='Europe/Moscow', 27 | # task_routes=task_routes, 28 | ) 29 | 30 | _celery.conf.beat_schedule = { 31 | 'cache-offers': { 32 | 'task': 'offer.tasks.cache_offers.cache_offers', 33 | 'schedule': 60 34 | }, 35 | } 36 | -------------------------------------------------------------------------------- /project/redis_conn.py: -------------------------------------------------------------------------------- 1 | import redis 2 | from django.conf import settings 3 | 4 | # redis_conn = Redis.from_url(settings.REDIS_URL) 5 | pool = redis.ConnectionPool.from_url(settings.REDIS_URL, max_connections=8) 6 | -------------------------------------------------------------------------------- /project/settings/__init__.py: -------------------------------------------------------------------------------- 1 | try: 2 | from .local import * # noqa 3 | except ImportError: 4 | from .prod import * # noqa 5 | -------------------------------------------------------------------------------- /project/settings/base.py: -------------------------------------------------------------------------------- 1 | import os 2 | import datetime 3 | 4 | 5 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 6 | BASE_DIR = os.path.dirname( 7 | os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 8 | ) 9 | 10 | 11 | # SECURITY WARNING: don't run with debug turned on in production! 12 | DEBUG = False 13 | 14 | ALLOWED_HOSTS = ['*'] 15 | 16 | 17 | # Application definition 18 | 19 | INSTALLED_APPS = [ 20 | 'django.contrib.admin', 21 | 'django.contrib.auth', 22 | 'django.contrib.contenttypes', 23 | 'django.contrib.sessions', 24 | 'django.contrib.messages', 25 | 'django.contrib.staticfiles', 26 | 27 | 'rest_framework', 28 | 'rest_framework_swagger', 29 | 'corsheaders', 30 | 'countries_plus', 31 | 'tinymce', 32 | 'drf_spectacular', 33 | 34 | 'offer', 35 | 'affiliate', 36 | 'network', 37 | 'tracker', 38 | 'user_profile', 39 | 'api', 40 | 'postback', 41 | ] 42 | 43 | 44 | MIDDLEWARE = [ 45 | 'django.middleware.security.SecurityMiddleware', 46 | 'whitenoise.middleware.WhiteNoiseMiddleware', 47 | 'django.contrib.sessions.middleware.SessionMiddleware', 48 | 'corsheaders.middleware.CorsMiddleware', 49 | 'django.middleware.common.CommonMiddleware', 50 | 'django.middleware.csrf.CsrfViewMiddleware', 51 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 52 | 'django.contrib.messages.middleware.MessageMiddleware', 53 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 54 | ] 55 | 56 | ROOT_URLCONF = 'project.urls' 57 | 58 | TEMPLATES = [ 59 | { 60 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 61 | 'DIRS': [], 62 | 'APP_DIRS': True, 63 | 'OPTIONS': { 64 | 'context_processors': [ 65 | 'django.template.context_processors.debug', 66 | 'django.template.context_processors.request', 67 | 'django.contrib.auth.context_processors.auth', 68 | 'django.contrib.messages.context_processors.messages', 69 | ], 70 | }, 71 | }, 72 | ] 73 | 74 | WSGI_APPLICATION = 'project.wsgi.application' 75 | 76 | 77 | # Database 78 | # https://docs.djangoproject.com/en/2.2/ref/settings/#databases 79 | 80 | # DATABASES = { 81 | # 'default': { 82 | # 'ENGINE': 'django.db.backends.sqlite3', 83 | # 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 84 | # } 85 | # } 86 | 87 | 88 | # Password validation 89 | # https://docs.djangoproject.com/en/2.2/ref/settings/#auth-password-validators 90 | 91 | AUTH_PASSWORD_VALIDATORS = [ 92 | { 93 | 'NAME': ( 94 | 'django.contrib.auth.password_validation' 95 | '.UserAttributeSimilarityValidator' 96 | ), 97 | }, 98 | { 99 | 'NAME': ( 100 | 'django.contrib.auth.password_validation.MinimumLengthValidator' 101 | ), 102 | }, 103 | { 104 | 'NAME': ( 105 | 'django.contrib.auth.password_validation' 106 | '.CommonPasswordValidator' 107 | ), 108 | }, 109 | { 110 | 'NAME': ( 111 | 'django.contrib.auth.password_validation.NumericPasswordValidator' 112 | ), 113 | }, 114 | ] 115 | 116 | 117 | # Internationalization 118 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 119 | 120 | LANGUAGE_CODE = 'en-us' 121 | 122 | TIME_ZONE = 'UTC' 123 | 124 | USE_I18N = True 125 | 126 | USE_L10N = True 127 | 128 | USE_TZ = True 129 | 130 | 131 | # Static files (CSS, JavaScript, Images) 132 | # https://docs.djangoproject.com/en/2.2/howto/static-files/ 133 | 134 | STATIC_URL = '/static/' 135 | 136 | STATIC_ROOT = os.path.join(BASE_DIR, 'staticfiles') 137 | 138 | STATICFILES_STORAGE = ( 139 | 'whitenoise.storage.CompressedManifestStaticFilesStorage' 140 | ) 141 | 142 | REST_FRAMEWORK = { 143 | 'DEFAULT_AUTHENTICATION_CLASSES': ( 144 | 'rest_framework_jwt.authentication.JSONWebTokenAuthentication', 145 | 'rest_framework.authentication.BasicAuthentication', 146 | 'rest_framework.authentication.SessionAuthentication', 147 | ), 148 | 'DEFAULT_PARSER_CLASSES': ( 149 | 'rest_framework.parsers.JSONParser', 150 | # 'rest_framework.parsers.MultiPartParser', 151 | # 'rest_framework.parsers.FormParser', 152 | ), 153 | 'DEFAULT_FILTER_BACKENDS': ( 154 | 'django_filters.rest_framework.DjangoFilterBackend', 155 | ), 156 | 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 157 | } 158 | 159 | 160 | JWT_AUTH = { 161 | 'JWT_EXPIRATION_DELTA': datetime.timedelta(seconds=60 * 60 * 24) 162 | } 163 | 164 | 165 | AUTHENTICATION_BACKENDS = [ 166 | 'django.contrib.auth.backends.ModelBackend', 167 | ] 168 | 169 | 170 | CORS_ORIGIN_ALLOW_ALL = True 171 | 172 | CORS_ALLOW_METHODS = ( 173 | 'DELETE', 174 | 'GET', 175 | 'OPTIONS', 176 | 'PATCH', 177 | 'POST', 178 | 'PUT', 179 | ) 180 | 181 | REDIS_URL = os.environ['REDIS_URL'] 182 | 183 | TRACKER_URL = os.environ['TRACKER_URL'] 184 | 185 | IPSTACK_TOKEN = os.environ['IPSTACK_TOKEN'] 186 | -------------------------------------------------------------------------------- /project/settings/local.dist.py: -------------------------------------------------------------------------------- 1 | import dj_database_url 2 | from .base import * # noqa 3 | 4 | 5 | # SECURITY WARNING: keep the secret key used in production secret! 6 | SECRET_KEY = '4wsaeuncy$^*l_fycbwqi_2g)5a$b6a%x0@l@6k=xxxxxxxxxx' 7 | 8 | DEBUG = True 9 | 10 | DATABASE_URL = "postgresql://postgres:postgres@postgres/postgres" 11 | 12 | DATABASES = { 13 | 'default': dj_database_url.config( 14 | default=DATABASE_URL, 15 | engine='django.db.backends.postgresql_psycopg2') 16 | } 17 | -------------------------------------------------------------------------------- /project/settings/prod.py: -------------------------------------------------------------------------------- 1 | import os 2 | import dj_database_url 3 | from .base import * # noqa 4 | 5 | 6 | SECRET_KEY = os.environ['DJ_SECRET_KEY'] 7 | 8 | 9 | DATABASE_URL = os.environ['DATABASE_URL'] 10 | 11 | DATABASES = { 12 | 'default': dj_database_url.config( 13 | default=DATABASE_URL, 14 | engine='django.db.backends.postgresql_psycopg2') 15 | } 16 | -------------------------------------------------------------------------------- /project/urls.py: -------------------------------------------------------------------------------- 1 | """project URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from rest_framework_jwt.views import obtain_jwt_token 17 | from django.contrib import admin 18 | from django.urls import path, include 19 | from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView 20 | 21 | 22 | urlpatterns = [ 23 | path('admin/', admin.site.urls), 24 | 25 | path('api/schema/', SpectacularAPIView.as_view(), name='schema'), 26 | path('api/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), 27 | 28 | path('auth/', obtain_jwt_token), 29 | 30 | path('affiliate/', include('affiliate.urls')), 31 | path('network/', include('network.urls')), 32 | path('', include('tracker.urls')), 33 | path('api/', include('dictionaries.urls')), 34 | path('tinymce/', include('tinymce.urls')), 35 | path('api/', include('api.urls')), 36 | ] 37 | -------------------------------------------------------------------------------- /project/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for project project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'project.settings') 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | DJANGO_SETTINGS_MODULE = project.settings 3 | python_files = tests.py test_*.py *_tests.py 4 | -------------------------------------------------------------------------------- /requirements.dev.txt: -------------------------------------------------------------------------------- 1 | django==3.1.14 2 | flake8 3 | psycopg2-binary 4 | dj-database-url 5 | django-filter 6 | djangorestframework 7 | djangorestframework-jwt 8 | django-cors-headers 9 | drf-spectacular 10 | PyYAML 11 | whitenoise 12 | django-rest-swagger 13 | django-countries-plus 14 | requests 15 | iso8601 16 | celery 17 | maxminddb-geolite2 18 | redis 19 | honcho 20 | gunicorn 21 | django-tinymce 22 | mypy 23 | django-stubs 24 | djangorestframework-stubs 25 | webargs -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | amqp==5.1.1 2 | asgiref==3.5.2 3 | async-timeout==4.0.2 4 | billiard==3.6.4.0 5 | celery==5.2.7 6 | certifi==2022.9.24 7 | charset-normalizer==2.1.1 8 | click==8.1.3 9 | click-didyoumean==0.3.0 10 | click-plugins==1.1.1 11 | click-repl==0.2.0 12 | coreapi==2.3.3 13 | coreschema==0.0.4 14 | dj-database-url==0.5.0 15 | Django==3.1.14 16 | django-cors-headers==3.11.0 17 | django-countries-plus==2.1.0 18 | django-filter==21.1 19 | django-rest-swagger==2.2.0 20 | django-stubs==1.13.0 21 | django-stubs-ext==0.7.0 22 | django-tinymce==3.5.0 23 | djangorestframework==3.14.0 24 | djangorestframework-jwt==1.11.0 25 | djangorestframework-stubs==1.8.0 26 | drf-spectacular==0.27.0 27 | flake8==6.0.0 28 | gunicorn==20.1.0 29 | honcho==1.1.0 30 | idna==3.4 31 | iso8601==1.1.0 32 | itypes==1.2.0 33 | Jinja2==3.1.2 34 | kombu==5.2.4 35 | MarkupSafe==2.1.1 36 | marshmallow==3.19.0 37 | maxminddb==2.2.0 38 | maxminddb-geolite2==2018.703 39 | mccabe==0.7.0 40 | mypy==0.991 41 | mypy-extensions==0.4.3 42 | openapi-codec==1.3.2 43 | packaging==21.3 44 | prompt-toolkit==3.0.33 45 | psycopg2-binary==2.9.5 46 | pycodestyle==2.10.0 47 | pyflakes==3.0.1 48 | PyJWT==1.7.1 49 | pyparsing==3.0.9 50 | pytz==2022.6 51 | PyYAML==6.0 52 | redis==4.3.5 53 | requests==2.28.1 54 | simplejson==3.18.0 55 | six==1.16.0 56 | sqlparse==0.4.3 57 | tomli==2.0.1 58 | types-pytz==2022.6.0.1 59 | types-PyYAML==6.0.12.2 60 | types-requests==2.28.11.5 61 | types-urllib3==1.26.25.4 62 | typing_extensions==4.4.0 63 | uritemplate==4.1.1 64 | urllib3==1.26.13 65 | vine==5.0.0 66 | wcwidth==0.2.5 67 | webargs==8.2.0 68 | whitenoise==6.2.0 69 | -------------------------------------------------------------------------------- /tracker/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'tracker.apps.TrackerConfig' 2 | -------------------------------------------------------------------------------- /tracker/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from .models import Click, Conversion 3 | 4 | 5 | admin.site.register(Click) 6 | 7 | 8 | @admin.register(Conversion) 9 | class ConversionAdmin(admin.ModelAdmin): 10 | list_display = ( 11 | 'id', 12 | 'created_at', 13 | 'affiliate', 14 | 'offer', 15 | 'revenue', 16 | 'payout', 17 | 'goal', 18 | 'status', 19 | 'ip', 20 | 'country', 21 | ) 22 | -------------------------------------------------------------------------------- /tracker/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class TrackerConfig(AppConfig): 5 | name = 'tracker' 6 | 7 | def ready(self): 8 | super(TrackerConfig, self).ready() 9 | import tracker.signals # noqa: F401 10 | -------------------------------------------------------------------------------- /tracker/dao.py: -------------------------------------------------------------------------------- 1 | import json 2 | import redis 3 | from offer.models import Payout 4 | from project.redis_conn import pool 5 | from typing import Optional, Dict 6 | 7 | 8 | def any(predicate, collection): 9 | return bool(len(list(filter(predicate, collection)))) 10 | 11 | 12 | def first(collection): 13 | try: 14 | return collection[0] 15 | except IndexError: 16 | return None 17 | 18 | 19 | def find_payout(offer_id: int, country: str, goal: str) -> Payout: 20 | return ( 21 | Payout.objects 22 | .filter( 23 | offer_id=offer_id, 24 | goal_value=goal, 25 | countries__in=[country] 26 | ) 27 | .first() 28 | ) 29 | # payouts = Payout.objects.filter(offer_id=offer_id, goal_value=goal) 30 | # return ( 31 | # first( 32 | # list( 33 | # filter( 34 | # lambda p: 35 | # any( 36 | # lambda c: 37 | # c.code == country, p.countries), 38 | # payouts)))) 39 | 40 | 41 | class TrackerCache(object): 42 | 43 | @staticmethod 44 | def get_offer(id: int) -> Optional[Dict]: 45 | redis_conn = redis.Redis(connection_pool=pool) 46 | resp = redis_conn.get(f"offers:{id}") 47 | if resp: 48 | return json.loads(resp) 49 | return None 50 | 51 | # @staticmethod 52 | # def get_user(id: int) -> dict: 53 | # redis_conn = redis.Redis(connection_pool=pool) 54 | # resp = redis_conn.get(f"users:{id}") 55 | # if resp: 56 | # return json.loads(resp) 57 | # return None 58 | -------------------------------------------------------------------------------- /tracker/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-17 17:44 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | import uuid 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 15 | ('offer', '0004_auto_20191009_1712'), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name='Conversion', 21 | fields=[ 22 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 23 | ('click_id', models.UUIDField(editable=False)), 24 | ('created_at', models.DateTimeField(auto_now_add=True)), 25 | ('click_date', models.DateTimeField()), 26 | ('sub1', models.CharField(default='', max_length=500)), 27 | ('sub2', models.CharField(default='', max_length=500)), 28 | ('sub3', models.CharField(default='', max_length=500)), 29 | ('sub4', models.CharField(default='', max_length=500)), 30 | ('sub5', models.CharField(default='', max_length=500)), 31 | ('revenue', models.DecimalField(decimal_places=2, max_digits=7)), 32 | ('payout', models.DecimalField(decimal_places=2, max_digits=7)), 33 | ('ip', models.GenericIPAddressField()), 34 | ('country', models.CharField(default='', max_length=2)), 35 | ('ua', models.CharField(default='', max_length=200)), 36 | ('goal', models.CharField(default='', max_length=20)), 37 | ('status', models.CharField(choices=[('approved', 'Approved'), ('hold', 'Hold'), ('rejected', 'Rejected')], default='rejected', max_length=10)), 38 | ('sum', models.FloatField(default=0.0)), 39 | ('affiliate', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='conversions', to=settings.AUTH_USER_MODEL)), 40 | ('offer', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='conversions', to='offer.Offer')), 41 | ], 42 | ), 43 | migrations.CreateModel( 44 | name='Click', 45 | fields=[ 46 | ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), 47 | ('created_at', models.DateTimeField(auto_now_add=True)), 48 | ('sub1', models.CharField(default='', max_length=500)), 49 | ('sub2', models.CharField(default='', max_length=500)), 50 | ('sub3', models.CharField(default='', max_length=500)), 51 | ('sub4', models.CharField(default='', max_length=500)), 52 | ('sub5', models.CharField(default='', max_length=500)), 53 | ('ip', models.GenericIPAddressField()), 54 | ('country', models.CharField(default='', max_length=2)), 55 | ('ua', models.CharField(default='', max_length=200)), 56 | ('revenue', models.DecimalField(decimal_places=2, max_digits=7)), 57 | ('payout', models.DecimalField(decimal_places=2, max_digits=7)), 58 | ('affiliate', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='clicks', to=settings.AUTH_USER_MODEL)), 59 | ('offer', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='clicks', to='offer.Offer')), 60 | ], 61 | ), 62 | ] 63 | -------------------------------------------------------------------------------- /tracker/migrations/0002_auto_20200618_1524.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-18 15:24 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('tracker', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='click', 18 | name='affiliate_manager', 19 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), 20 | ), 21 | migrations.AddField( 22 | model_name='conversion', 23 | name='affiliate_manager', 24 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /tracker/migrations/0003_auto_20200629_2057.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-06-29 20:57 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('tracker', '0002_auto_20200618_1524'), 10 | ] 11 | 12 | operations = [ 13 | migrations.RenameField( 14 | model_name='conversion', 15 | old_name='goal', 16 | new_name='goal_value', 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tracker/migrations/0004_conversion_goal.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-06-29 21:10 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('offer', '0015_offer_advertiser'), 11 | ('tracker', '0003_auto_20200629_2057'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='conversion', 17 | name='goal', 18 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='offer.Goal'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /tracker/migrations/0005_conversion_currency.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-06-29 21:20 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('offer', '0015_offer_advertiser'), 11 | ('tracker', '0004_conversion_goal'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name='conversion', 17 | name='currency', 18 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='offer.Currency'), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /tracker/migrations/0006_conversion_comment.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-06-29 22:15 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('tracker', '0005_conversion_currency'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name='conversion', 15 | name='comment', 16 | field=models.CharField(blank=True, default='', max_length=128), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tracker/migrations/0007_auto_20200803_1648.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-08-03 16:48 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('tracker', '0006_conversion_comment'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='conversion', 15 | name='click_id', 16 | field=models.UUIDField(default=None, editable=False, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tracker/migrations/0008_auto_20200803_1822.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-08-03 18:22 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('tracker', '0007_auto_20200803_1648'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='conversion', 15 | name='click_date', 16 | field=models.DateTimeField(default=None, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tracker/migrations/0009_auto_20200803_1824.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-08-03 18:24 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('tracker', '0008_auto_20200803_1822'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='conversion', 15 | name='payout', 16 | field=models.DecimalField(decimal_places=2, default=0.0, max_digits=7), 17 | ), 18 | migrations.AlterField( 19 | model_name='conversion', 20 | name='revenue', 21 | field=models.DecimalField(decimal_places=2, default=0.0, max_digits=7), 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /tracker/migrations/0010_auto_20200803_1825.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-08-03 18:25 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('tracker', '0009_auto_20200803_1824'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='conversion', 15 | name='ip', 16 | field=models.GenericIPAddressField(default=None, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tracker/migrations/0011_auto_20200826_2206.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-08-26 22:06 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('tracker', '0010_auto_20200803_1825'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name='conversion', 15 | options={'ordering': ('-created_at',)}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /tracker/migrations/0012_auto_20200914_1037.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-09-14 10:37 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('tracker', '0011_auto_20200826_2206'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='conversion', 15 | name='click_date', 16 | field=models.DateTimeField(blank=True, default=None, null=True), 17 | ), 18 | migrations.AlterField( 19 | model_name='conversion', 20 | name='country', 21 | field=models.CharField(blank=True, default='', max_length=2), 22 | ), 23 | migrations.AlterField( 24 | model_name='conversion', 25 | name='ip', 26 | field=models.GenericIPAddressField(blank=True, default=None, null=True), 27 | ), 28 | migrations.AlterField( 29 | model_name='conversion', 30 | name='sub1', 31 | field=models.CharField(blank=True, default='', max_length=500), 32 | ), 33 | migrations.AlterField( 34 | model_name='conversion', 35 | name='sub2', 36 | field=models.CharField(blank=True, default='', max_length=500), 37 | ), 38 | migrations.AlterField( 39 | model_name='conversion', 40 | name='sub3', 41 | field=models.CharField(blank=True, default='', max_length=500), 42 | ), 43 | migrations.AlterField( 44 | model_name='conversion', 45 | name='sub4', 46 | field=models.CharField(blank=True, default='', max_length=500), 47 | ), 48 | migrations.AlterField( 49 | model_name='conversion', 50 | name='sub5', 51 | field=models.CharField(blank=True, default='', max_length=500), 52 | ), 53 | migrations.AlterField( 54 | model_name='conversion', 55 | name='ua', 56 | field=models.CharField(blank=True, default='', max_length=200), 57 | ), 58 | ] 59 | -------------------------------------------------------------------------------- /tracker/migrations/0013_auto_20201018_0101.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.7 on 2020-10-18 01:01 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ('tracker', '0012_auto_20200914_1037'), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name='conversion', 15 | name='status', 16 | field=models.CharField(choices=[('approved', 'Approved'), ('hold', 'Hold'), ('rejected', 'Rejected'), ('pending', 'Pending')], default='rejected', max_length=10), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /tracker/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/tracker/migrations/__init__.py -------------------------------------------------------------------------------- /tracker/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.db import models 3 | from offer.models import Offer, Goal, Currency 4 | from django.contrib.auth import get_user_model 5 | 6 | 7 | class Click(models.Model): 8 | id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 9 | created_at = models.DateTimeField(auto_now_add=True) 10 | sub1 = models.CharField(max_length=500, default="") 11 | sub2 = models.CharField(max_length=500, default="") 12 | sub3 = models.CharField(max_length=500, default="") 13 | sub4 = models.CharField(max_length=500, default="") 14 | sub5 = models.CharField(max_length=500, default="") 15 | ip = models.GenericIPAddressField() 16 | country = models.CharField(max_length=2, default="") 17 | ua = models.CharField(max_length=200, default="") 18 | revenue = models.DecimalField(max_digits=7, decimal_places=2) 19 | payout = models.DecimalField(max_digits=7, decimal_places=2) 20 | 21 | offer = models.ForeignKey( 22 | Offer, 23 | related_name='clicks', 24 | on_delete=models.SET_NULL, 25 | null=True 26 | ) 27 | 28 | affiliate = models.ForeignKey( 29 | get_user_model(), 30 | related_name='clicks', 31 | on_delete=models.SET_NULL, 32 | null=True 33 | ) 34 | 35 | affiliate_manager = models.ForeignKey( 36 | get_user_model(), 37 | # related_name='clicks', 38 | on_delete=models.SET_NULL, 39 | null=True, 40 | blank=True, 41 | ) 42 | 43 | 44 | APPROVED_STATUS = 'approved' 45 | HOLD_STATUS = 'hold' 46 | REJECTED_STATUS = 'rejected' 47 | PENDING_STATUS = 'pending' 48 | conversion_statuses = ( 49 | (APPROVED_STATUS, 'Approved',), 50 | (HOLD_STATUS, 'Hold',), 51 | (REJECTED_STATUS, 'Rejected',), 52 | (PENDING_STATUS, 'Pending',), 53 | ) 54 | 55 | 56 | class Conversion(models.Model): 57 | 58 | class Meta: 59 | ordering = ('-created_at',) 60 | 61 | id = models.UUIDField( 62 | primary_key=True, default=uuid.uuid4, editable=False) 63 | click_id = models.UUIDField(editable=False, null=True, default=None) 64 | created_at = models.DateTimeField(auto_now_add=True) 65 | click_date = models.DateTimeField(null=True, default=None, blank=True) 66 | sub1 = models.CharField(max_length=500, default="", blank=True) 67 | sub2 = models.CharField(max_length=500, default="", blank=True) 68 | sub3 = models.CharField(max_length=500, default="", blank=True) 69 | sub4 = models.CharField(max_length=500, default="", blank=True) 70 | sub5 = models.CharField(max_length=500, default="", blank=True) 71 | revenue = models.DecimalField(max_digits=7, decimal_places=2, default=.0) 72 | payout = models.DecimalField(max_digits=7, decimal_places=2, default=.0) 73 | ip = models.GenericIPAddressField(null=True, default=None, blank=True) 74 | country = models.CharField(max_length=2, default="", blank=True) 75 | ua = models.CharField(max_length=200, default="", blank=True) 76 | goal_value = models.CharField(max_length=20, default="") 77 | status = models.CharField( 78 | max_length=10, choices=conversion_statuses, default=REJECTED_STATUS) 79 | sum = models.FloatField(default=0.0) 80 | comment = models.CharField(max_length=128, default='', blank=True) 81 | 82 | goal = models.ForeignKey( 83 | Goal, 84 | on_delete=models.SET_NULL, 85 | null=True, 86 | blank=True, 87 | ) 88 | 89 | currency = models.ForeignKey( 90 | Currency, 91 | on_delete=models.SET_NULL, 92 | null=True, 93 | blank=True, 94 | ) 95 | 96 | offer = models.ForeignKey( 97 | Offer, 98 | related_name='conversions', 99 | on_delete=models.SET_NULL, 100 | null=True 101 | ) 102 | 103 | affiliate = models.ForeignKey( 104 | get_user_model(), 105 | related_name='conversions', 106 | on_delete=models.SET_NULL, 107 | null=True 108 | ) 109 | 110 | affiliate_manager = models.ForeignKey( 111 | get_user_model(), 112 | # related_name='conversions', 113 | on_delete=models.SET_NULL, 114 | null=True, 115 | blank=True, 116 | ) 117 | -------------------------------------------------------------------------------- /tracker/signals.py: -------------------------------------------------------------------------------- 1 | from django.dispatch import receiver 2 | from django.db.models.signals import post_save 3 | from .models import Conversion 4 | from postback.tasks.send_postback import send_postback 5 | 6 | 7 | @receiver(post_save, sender=Conversion) 8 | def on_conversion_created(sender, instance, created, **kwargs): 9 | if created: 10 | cv = { 11 | 'affiliate_id': instance.affiliate_id, 12 | 'offer_id': instance.offer_id, 13 | 'sub1': instance.sub1, 14 | 'sub2': instance.sub2, 15 | 'sub3': instance.sub3, 16 | 'sub4': instance.sub4, 17 | 'sub5': instance.sub5, 18 | 'payout': instance.payout, 19 | 'goal_value': instance.goal_value, 20 | 'currency': instance.currency.code if instance.currency else '', 21 | } 22 | send_postback.delay(cv) 23 | -------------------------------------------------------------------------------- /tracker/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | # from .sync import sync # noqa 2 | # from .click import click # noqa 3 | # from .conversion import conversion # noqa 4 | # from .on_deposit import on_deposit # noqa 5 | -------------------------------------------------------------------------------- /tracker/tasks/click.py: -------------------------------------------------------------------------------- 1 | from geolite2 import geolite2 2 | from django.contrib.auth import get_user_model 3 | from project._celery import _celery 4 | from tracker.models import Click 5 | from ext.ipapi import API, Err as IpstackErr 6 | 7 | 8 | def detect_country(ip: str) -> str: 9 | reader = geolite2.reader() 10 | ip_info = reader.get(ip) or {} 11 | country = ip_info.get("country", {}).get("iso_code", "") 12 | return country 13 | 14 | 15 | def detect_country_service(ip: str) -> str: 16 | api = API() 17 | try: 18 | resp = api.query(ip) 19 | except IpstackErr: 20 | return '' 21 | return resp.country_code 22 | 23 | 24 | @_celery.task 25 | def click(data): 26 | country = detect_country_service(data["ip"]) 27 | 28 | try: 29 | user = get_user_model().objects.get(pk=data['pid']) 30 | except get_user_model().DoesNotExist: 31 | msg = f"affiliate {data['pid']} not found" 32 | print(msg) 33 | return msg 34 | 35 | click = Click() 36 | click.id = data['click_id'] 37 | click.offer_id = data['offer_id'] 38 | click.affiliate_id = data['pid'] 39 | click.affiliate_manager = user.profile.manager 40 | click.sub1 = data['sub1'] 41 | click.sub2 = data['sub2'] 42 | click.sub3 = data['sub3'] 43 | click.sub4 = data['sub4'] 44 | click.sub5 = data['sub5'] 45 | click.revenue = 0 46 | click.payout = 0 47 | click.ip = data['ip'] 48 | click.country = country 49 | click.ua = data['ua'] 50 | click.save() 51 | 52 | return f"Click created: {click.id}" 53 | -------------------------------------------------------------------------------- /tracker/tasks/conversion.py: -------------------------------------------------------------------------------- 1 | from project._celery import _celery 2 | from tracker.models import ( 3 | Click, 4 | Conversion, 5 | HOLD_STATUS, 6 | REJECTED_STATUS, 7 | ) 8 | from offer.models import Payout 9 | 10 | 11 | @_celery.task 12 | def conversion(data): 13 | try: 14 | click = Click.objects.get(pk=data['click_id']) 15 | except Click.DoesNotExist: 16 | return f"Click not found. click_id: {data['click_id']}" 17 | 18 | existing_conversion = ( 19 | Conversion.objects 20 | .filter(click_id=click.id, goal_value=data['goal']) 21 | .first() 22 | ) 23 | if existing_conversion and data.get('status'): 24 | if existing_conversion.status == HOLD_STATUS: 25 | existing_conversion.status = data.get('status') 26 | existing_conversion.save() 27 | return f"Processed conversion for click_id: {data['click_id']}" 28 | 29 | duplicate = bool(existing_conversion) 30 | 31 | conversion = Conversion() 32 | 33 | payout = ( 34 | Payout.objects 35 | .filter( 36 | offer_id=click.offer_id, 37 | goal_value=data['goal'], 38 | countries__in=[click.country] 39 | ) 40 | .first() 41 | ) 42 | 43 | if payout: 44 | conversion.revenue = payout.revenue 45 | conversion.payout = payout.payout 46 | conversion.goal = payout.goal 47 | conversion.currency = payout.currency 48 | 49 | if data.get('status'): 50 | conversion.status = data.get('status') 51 | else: 52 | conversion.status = HOLD_STATUS 53 | 54 | if duplicate: 55 | conversion.status = REJECTED_STATUS 56 | conversion.comment = 'Duplicate Click ID' 57 | else: 58 | conversion.revenue = 0 59 | conversion.payout = 0 60 | conversion.status = REJECTED_STATUS 61 | conversion.comment = 'Payout not found' 62 | 63 | conversion.click_id = click.id 64 | conversion.click_date = click.created_at 65 | 66 | conversion.offer_id = click.offer.id 67 | conversion.affiliate_id = click.affiliate.id 68 | conversion.affiliate_manager = click.affiliate.profile.manager 69 | conversion.sub1 = click.sub1 70 | conversion.sub2 = click.sub2 71 | conversion.sub3 = click.sub3 72 | conversion.sub4 = click.sub4 73 | conversion.sub5 = click.sub5 74 | conversion.ip = click.ip 75 | conversion.ua = click.ua 76 | conversion.country = click.country 77 | 78 | conversion.goal_value = data['goal'] 79 | conversion.sum = data['sum'] 80 | 81 | conversion.save() 82 | 83 | return f"Processed conversion for click_id: {data['click_id']}" 84 | -------------------------------------------------------------------------------- /tracker/tasks/sync.py: -------------------------------------------------------------------------------- 1 | from project._celery import _celery 2 | 3 | 4 | @_celery.task 5 | def sync(): 6 | return 'sync done' 7 | -------------------------------------------------------------------------------- /tracker/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/tracker/tests/__init__.py -------------------------------------------------------------------------------- /tracker/tests/test_click_task.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from django.test import TestCase 3 | from ..tasks.click import click 4 | from ..models import Click 5 | from django.contrib.auth import get_user_model 6 | from offer.models import Offer 7 | 8 | 9 | class TestClickTask(TestCase): 10 | 11 | def setUp(self): 12 | super(TestClickTask, self).setUp() 13 | self.user = ( 14 | get_user_model().objects 15 | .create_user(username='aff', password='1234') 16 | ) 17 | self.manager = ( 18 | get_user_model().objects 19 | .create_user(username='manager', password='1234') 20 | ) 21 | self.user.profile.manager = self.manager 22 | self.user.profile.save() 23 | self.offer = Offer.objects.create(title='blabla', description='blabla') 24 | 25 | def test_click_create(self): 26 | click_id = uuid.uuid4() 27 | data = { 28 | 'click_id': click_id, 29 | 'offer_id': self.offer.pk, 30 | 'pid': self.user.pk, 31 | 'ip': '4.4.4.4', 32 | 'ua': 'Mozilla /5.0', 33 | 'sub1': '', 34 | 'sub2': '', 35 | 'sub3': '', 36 | 'sub4': '', 37 | 'sub5': '' 38 | } 39 | click(data) 40 | cl = Click.objects.get(pk=click_id) 41 | self.assertTrue(Click.objects.count()) 42 | self.assertEqual(cl.country, 'US') 43 | self.assertEqual(cl.affiliate_manager_id, self.manager.pk) 44 | -------------------------------------------------------------------------------- /tracker/tests/test_click_view.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from unittest import mock 3 | from django.urls import reverse 4 | 5 | 6 | class TestClickHanlder(TestCase): 7 | 8 | @mock.patch('tracker.dao.TrackerCache') 9 | def test_it_returns_400(self, mock_tracker_cache): 10 | url = reverse('tracker-click') 11 | response = self.client.get(url) 12 | self.assertEqual(400, response.status_code) 13 | 14 | @mock.patch('tracker.dao.TrackerCache.get_offer', return_value=None) 15 | def test_it_returns_404(self, mock_tracker_cache): 16 | url = reverse('tracker-click') 17 | response = self.client.get(url, {'pid': 1, 'offer_id': 1}) 18 | self.assertEqual(404, response.status_code) 19 | 20 | @mock.patch('tracker.views.click_task') 21 | @mock.patch( 22 | 'tracker.dao.TrackerCache.get_offer', 23 | return_value={'tracking_link': 'http://example.com'} 24 | ) 25 | def test_it_returns_302(self, mock_tracker_cache, mock_click_task): 26 | url = reverse('tracker-click') 27 | response = self.client.get( 28 | url, 29 | {'pid': 1, 'offer_id': 1}, 30 | HTTP_USER_AGENT='Mozilla/5.0' 31 | ) 32 | self.assertEqual(302, response.status_code) 33 | self.assertTrue(mock_click_task.delay.called) 34 | -------------------------------------------------------------------------------- /tracker/tests/test_conversion_task.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from countries_plus.models import Country 3 | from ..tasks.conversion import conversion 4 | from ..models import Click, Conversion 5 | from django.contrib.auth import get_user_model 6 | from offer.models import Offer, Payout, Currency 7 | 8 | 9 | class TestConversionTask(TestCase): 10 | 11 | def setUp(self): 12 | super(TestConversionTask, self).setUp() 13 | self.user = ( 14 | get_user_model().objects 15 | .create_user(username='aff', password='1234') 16 | ) 17 | self.offer = Offer.objects.create( 18 | id=1, title='blabla', description='blabla' 19 | ) 20 | 21 | def test_conversion_create(self): 22 | cl = Click.objects.create( 23 | ip='4.4.4.4', 24 | revenue=0, 25 | payout=0, 26 | offer=self.offer, 27 | affiliate=self.user 28 | ) 29 | data = { 30 | 'click_id': cl.id, 31 | 'goal': 1, 32 | 'sum': 0 33 | } 34 | conversion(data) 35 | self.assertTrue( 36 | Conversion.objects 37 | .filter(click_id=cl.id) 38 | .count() 39 | ) 40 | 41 | def test_with_status(self): 42 | cl = Click.objects.create( 43 | ip='4.4.4.4', 44 | country='US', 45 | revenue=0, 46 | payout=0, 47 | offer=self.offer, 48 | affiliate=self.user 49 | ) 50 | p = Payout.objects.create( 51 | revenue=0, 52 | payout=0, 53 | goal_value='1', 54 | offer=cl.offer, 55 | currency=Currency.objects.create(code='USD', name='USD'), 56 | ) 57 | p.countries.add( 58 | Country.objects.create( 59 | iso='US', iso3='USA', iso_numeric=1, name='USA' 60 | ) 61 | ) 62 | data = { 63 | 'click_id': cl.id, 64 | 'goal': 1, 65 | 'sum': 0, 66 | 'status': 'approved' 67 | } 68 | conversion(data) 69 | cv = ( 70 | Conversion.objects 71 | .filter(click_id=cl.id) 72 | .first()) 73 | self.assertEqual(cv.status, 'approved') 74 | -------------------------------------------------------------------------------- /tracker/tests/test_find_payout.py: -------------------------------------------------------------------------------- 1 | # from django.test import TestCase 2 | # from unittest import mock 3 | # from django.urls import reverse 4 | # from offer.models import Offer, Payout, Advertiser, Currency 5 | # from ..dao import find_payout 6 | 7 | 8 | # class TestFindPayout(TestCase): 9 | 10 | # def setUp(self): 11 | # super(TestFindPayout, self).setUp() 12 | # Offer.objects.create( 13 | # id=1, 14 | # title='blabla', 15 | # description='blabla', 16 | # advertiser=Advertiser.objects.create( 17 | # company='x', 18 | # email='x', 19 | # comment='x' 20 | # ) 21 | # ) 22 | # payout = Payout.objects.create( 23 | # offer_id=1, 24 | # revenue=0, 25 | # payout=0, 26 | # goal_value='2', 27 | # currency=Currency.objects.create(code='USD', name='Dollar') 28 | # ) 29 | # payout.countries.add('RU') 30 | 31 | # def test_payout_found(self): 32 | # self.assertTrue(bool(find_payout(1, 'RU', '2'))) 33 | -------------------------------------------------------------------------------- /tracker/tests/test_postback_view.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | from unittest import mock 3 | from django.urls import reverse 4 | 5 | 6 | class TestPostbackHanlder(TestCase): 7 | 8 | def test_returns_400(self): 9 | url = reverse('tracker-postback') 10 | response = self.client.get(url) 11 | self.assertEqual(400, response.status_code) 12 | 13 | @mock.patch('tracker.views.conversion') 14 | def test_returns_200(self, mock_conversion): 15 | url = reverse('tracker-postback') 16 | response = self.client.get(url, {'click_id': 1, 'goal': 1}) 17 | self.assertEqual(200, response.status_code) 18 | self.assertTrue(mock_conversion.delay.called) 19 | -------------------------------------------------------------------------------- /tracker/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | 6 | urlpatterns = [ 7 | path('click', views.click, name='tracker-click'), 8 | path('postback', views.postback, name='tracker-postback'), 9 | ] 10 | -------------------------------------------------------------------------------- /tracker/views.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from typing import Dict, Any 3 | from django.shortcuts import redirect 4 | from django.http import HttpResponse, HttpRequest 5 | from tracker.tasks.click import click as click_task 6 | from tracker.tasks.conversion import conversion 7 | from tracker.dao import TrackerCache 8 | from .models import conversion_statuses 9 | 10 | 11 | # class ClickData(NamedTuple): 12 | # click_id: str 13 | # offer_id: int 14 | # pid: int 15 | # ip: str 16 | # ua: str 17 | # sub1: str 18 | # sub2: str 19 | # sub3: str 20 | # sub4: str 21 | # sub5: str 22 | # fb_id: str 23 | 24 | 25 | def get_client_ip(request: HttpRequest) -> str: 26 | x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') 27 | if x_forwarded_for: 28 | ip = x_forwarded_for.split(',')[0] 29 | else: 30 | ip = request.META.get('REMOTE_ADDR') 31 | return ip 32 | 33 | 34 | def replace_macro(url: str, context: Dict[str, Any]) -> str: 35 | url = url.replace('{click_id}', context['click_id']) 36 | url = url.replace('{clickid}', context['click_id']) 37 | url = url.replace('{pid}', context['pid']) 38 | url = url.replace('{fb_id}', context['fb_id']) 39 | return url 40 | 41 | 42 | def click(request): 43 | offer_id = request.GET.get('offer_id') 44 | pid = request.GET.get('pid') 45 | if not offer_id or not pid: 46 | return HttpResponse("Missing parameters", status=400) 47 | 48 | offer_data = TrackerCache.get_offer(offer_id) 49 | if not offer_data: 50 | return HttpResponse(status=404) 51 | 52 | click_id = uuid.uuid4().hex 53 | 54 | data = { 55 | 'click_id': click_id, 56 | 'offer_id': offer_id, 57 | 'pid': pid, 58 | 'ip': get_client_ip(request), 59 | 'ua': request.META['HTTP_USER_AGENT'], 60 | 'sub1': request.GET.get('sub1', ""), 61 | 'sub2': request.GET.get('sub2', ""), 62 | 'sub3': request.GET.get('sub3', ""), 63 | 'sub4': request.GET.get('sub4', ""), 64 | 'sub5': request.GET.get('sub5', ""), 65 | } 66 | 67 | click_task.delay(data) 68 | 69 | context = { 70 | 'click_id': click_id, 71 | 'pid': pid, 72 | 'fb_id': request.GET.get('fb_id', ""), 73 | } 74 | url = replace_macro(offer_data['tracking_link'], context) 75 | 76 | return redirect(url) 77 | 78 | 79 | def postback(request): 80 | click_id = request.GET.get('click_id') 81 | goal = request.GET.get('goal', '1') 82 | status = request.GET.get('status') 83 | 84 | try: 85 | sum_ = float(request.GET.get('sum', '')) 86 | except ValueError: 87 | sum_ = 0.0 88 | 89 | if not click_id: 90 | resp = HttpResponse("Missing click_id") 91 | resp.status_code = 400 92 | return resp 93 | 94 | data = { 95 | 'click_id': click_id, 96 | 'goal': goal, 97 | 'sum': sum_, 98 | } 99 | 100 | available_status_codes = list(map(lambda t: t[0], conversion_statuses)) 101 | if status in available_status_codes: 102 | data.update({'status': status}) 103 | 104 | conversion.delay(data) 105 | 106 | return HttpResponse("Conversion logged") 107 | -------------------------------------------------------------------------------- /user_profile/__init__.py: -------------------------------------------------------------------------------- 1 | default_app_config = 'user_profile.apps.UserProfileConfig' 2 | -------------------------------------------------------------------------------- /user_profile/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from user_profile.models import Profile 4 | 5 | admin.site.register(Profile) 6 | -------------------------------------------------------------------------------- /user_profile/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class UserProfileConfig(AppConfig): 5 | name = 'user_profile' 6 | 7 | def ready(self): 8 | super(UserProfileConfig, self).ready() 9 | import user_profile.signals # noqa: F401 10 | -------------------------------------------------------------------------------- /user_profile/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-17 21:48 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | initial = True 11 | 12 | dependencies = [ 13 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 14 | ] 15 | 16 | operations = [ 17 | migrations.CreateModel( 18 | name='Profile', 19 | fields=[ 20 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 21 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), 22 | ], 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /user_profile/migrations/0002_profile_manager.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-17 21:55 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | migrations.swappable_dependency(settings.AUTH_USER_MODEL), 12 | ('user_profile', '0001_initial'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AddField( 17 | model_name='profile', 18 | name='manager', 19 | field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='affiliates', to=settings.AUTH_USER_MODEL), 20 | ), 21 | ] 22 | -------------------------------------------------------------------------------- /user_profile/migrations/0003_auto_20200617_2157.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.6 on 2020-06-17 21:57 2 | 3 | from django.conf import settings 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('user_profile', '0002_profile_manager'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterField( 16 | model_name='profile', 17 | name='manager', 18 | field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='affiliates', to=settings.AUTH_USER_MODEL), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /user_profile/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/user_profile/migrations/__init__.py -------------------------------------------------------------------------------- /user_profile/models.py: -------------------------------------------------------------------------------- 1 | from django.contrib.auth import get_user_model 2 | from django.db import models 3 | 4 | User = get_user_model() 5 | 6 | 7 | class Profile(models.Model): 8 | user = models.OneToOneField(User, on_delete=models.CASCADE) 9 | 10 | manager = models.ForeignKey( 11 | get_user_model(), 12 | related_name='affiliates', 13 | on_delete=models.SET_NULL, 14 | null=True, 15 | blank=True 16 | ) 17 | 18 | def __str__(self): 19 | return self.user.username 20 | -------------------------------------------------------------------------------- /user_profile/signals.py: -------------------------------------------------------------------------------- 1 | from django.db.models.signals import post_save 2 | from django.dispatch import receiver 3 | 4 | from user_profile.models import Profile, User 5 | 6 | 7 | @receiver(post_save, sender=User) 8 | def create_user_profile(sender, instance, created, **kwargs): 9 | if created: 10 | Profile.objects.create(user=instance) 11 | -------------------------------------------------------------------------------- /user_profile/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cpanova/cpa-network/1b5d1fbb3dfaa64441ee0be505b9eb75b44cb659/user_profile/tests/__init__.py -------------------------------------------------------------------------------- /user_profile/tests/test_profile.py: -------------------------------------------------------------------------------- 1 | from rest_framework.test import APITestCase 2 | 3 | from user_profile.models import User 4 | 5 | 6 | class ProfileTestCase(APITestCase): 7 | def setUp(self): 8 | super(ProfileTestCase, self).setUp() 9 | credentials = { 10 | 'username': 'test@test.com', 11 | 'password': '1234', 12 | } 13 | self.user = User.objects.create_user(**credentials) 14 | self.user.first_name = 'test username' 15 | self.user.save() 16 | # self.user.profile.phone = '+7 123 456 7890' 17 | # self.user.profile.skype = '@test' 18 | # self.user.profile.save() 19 | self.client.login(**credentials) 20 | 21 | # def test_get_profile(self): 22 | # response = self.client.get('/api/profile/') 23 | # self.assertEqual(200, response.status_code) 24 | # # self.assertEqual( 25 | # # { 26 | # # 'first_name': 'test username', 27 | # # 'phone': '+7 123 456 7890', 28 | # # 'skype': '@test', 29 | # # }, 30 | # # response.data 31 | # # ) 32 | 33 | # def test_patch_profile(self): 34 | # response = self.client.patch('/api/profile/', 35 | # {'phone': '+1 123 456 7890'}) 36 | # self.assertEqual(200, response.status_code) 37 | # # self.assertEqual( 38 | # # { 39 | # # 'first_name': 'test username', 40 | # # 'phone': '+1 123 456 7890', 41 | # # 'skype': '@test', 42 | # # }, 43 | # # response.data 44 | # # ) 45 | 46 | # def test_patch_first_name(self): 47 | # response = self.client.patch('/api/profile/', 48 | # {'first_name': 'test test'}) 49 | # self.assertEqual(200, response.status_code) 50 | # self.assertEqual( 51 | # { 52 | # 'first_name': 'test test', 53 | # 'phone': '+7 123 456 7890', 54 | # 'skype': '@test', 55 | # }, 56 | # response.data 57 | # ) 58 | # self.assertEqual( 59 | # 'test test', 60 | # User.objects.get(pk=self.user.pk).first_name 61 | # ) 62 | -------------------------------------------------------------------------------- /user_profile/tests/test_signal.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase 2 | 3 | from user_profile.models import Profile, User 4 | 5 | 6 | class SignalTestCase(TestCase): 7 | def test_create_profile(self): 8 | user = User.objects.create_user(username='test@test.com') 9 | self.assertEqual( 10 | 1, 11 | Profile.objects.filter(user=user).count() 12 | ) 13 | --------------------------------------------------------------------------------